@mobius-os/mobius 0.2.2

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.
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Prep flow: bind the current cwd to a project, then walk through the
3
+ * preference wizard (Issue → model → language → skills → memories).
4
+ *
5
+ * Local-state sources (per the TUI spec):
6
+ * ~/.mobius/dir2project.json — cwd → projectId
7
+ * ~/.mobius/projects.json — cached project list
8
+ * ~/.mobius/dir2project_preference.json — cwd → active issue + per-issue prefs
9
+ *
10
+ * Preferences are stored INSIDE the selected Issue (switching issues restores
11
+ * that issue's saved model/language/skill/memory choices).
12
+ */
13
+ import React, { useEffect, useState } from 'react'
14
+ import { Box, Text } from 'ink'
15
+ import { Select, TextInput, type SelectItem } from './primitives.js'
16
+ import { MobiusClient } from '../api.js'
17
+ import {
18
+ bindCwdToProject, cwd, getCwdPreference, loadDir2Project, loadProjectsCache,
19
+ saveProjectsCache, setCwdIssue, updateIssuePreference, type IssuePreference,
20
+ } from '../config.js'
21
+ import type { Issue, Memory, Project, SessionModelOption, Skill } from '../types.js'
22
+
23
+ type PrefStep = 'issue' | 'model' | 'language' | 'skills' | 'memories'
24
+ const STEP_ORDER: PrefStep[] = ['model', 'language', 'skills', 'memories']
25
+
26
+ export interface ReadyState {
27
+ project: Project
28
+ issue: Issue
29
+ prefs: IssuePreference
30
+ }
31
+
32
+ export function PrepScreen({ client, onReady, onQuit }: {
33
+ client: MobiusClient
34
+ onReady: (st: ReadyState) => void
35
+ onQuit?: () => void
36
+ }) {
37
+ const [phase, setPhase] = useState<'loading' | 'project' | 'pref' | 'done'>('loading')
38
+ const [projects, setProjects] = useState<Project[]>([])
39
+ const [project, setProject] = useState<Project | null>(null)
40
+ const [issues, setIssues] = useState<Issue[]>([])
41
+ const [issueId, setIssueId] = useState<string | undefined>()
42
+ const [issue, setIssue] = useState<Issue | null>(null)
43
+ const [prefs, setPrefs] = useState<IssuePreference>({ excluded_skill_ids: [], excluded_memory_ids: [] })
44
+ const [step, setStep] = useState<PrefStep | null>(null)
45
+ const [modelOpts, setModelOpts] = useState<SessionModelOption[]>([])
46
+ const [skills, setSkills] = useState<Skill[]>([])
47
+ const [memories, setMemories] = useState<Memory[]>([])
48
+ const [defaultModel, setDefaultModel] = useState<string | null>(null)
49
+ const [statusMsg, setStatusMsg] = useState<string>('')
50
+ const thisCwd = cwd()
51
+
52
+ // ── bootstrap ────────────────────────────────────────────────────────────
53
+ useEffect(() => { (async () => {
54
+ setStatusMsg('加载项目列表…')
55
+ let list = await loadProjectsCache()
56
+ try { list = await client.listProjects(); await saveProjectsCache(list) } catch { /* use cache */ }
57
+ setProjects(list)
58
+ const d2p = await loadDir2Project()
59
+ const boundId = d2p[thisCwd]
60
+ if (boundId) {
61
+ const p = list.find(x => x.id === boundId) ?? { id: boundId, name: boundId } as Project
62
+ await enterProject(p, list)
63
+ } else {
64
+ setStatusMsg('')
65
+ setPhase('project')
66
+ }
67
+ })().catch(e => setStatusMsg(`初始化失败: ${e?.message ?? e}`)) }, [])
68
+
69
+ async function enterProject(p: Project, list?: Project[]) {
70
+ setProject(p)
71
+ if (list) setProjects(list)
72
+ setStatusMsg(`加载任务列表…`)
73
+ let iss: Issue[] = []
74
+ try { iss = await client.listIssues(p.id, 'active') } catch { /* empty */ }
75
+ setIssues(iss)
76
+ const cwdPref = await getCwdPreference(thisCwd)
77
+ let curPrefs: IssuePreference = { excluded_skill_ids: [], excluded_memory_ids: [] }
78
+ if (cwdPref.issueId && iss.some(i => i.id === cwdPref.issueId)) {
79
+ const foundIssue = iss.find(i => i.id === cwdPref.issueId) as Issue
80
+ curPrefs = cwdPref.prefs[cwdPref.issueId] ?? curPrefs
81
+ setIssue(foundIssue)
82
+ setIssueId(cwdPref.issueId)
83
+ setPrefs(curPrefs)
84
+ const next = computeStep(curPrefs)
85
+ if (next === null) {
86
+ // all preferences already configured for this issue → go straight to chat.
87
+ // (Call onReady directly: finish() reads `project` from closure, which hasn't
88
+ // committed yet at bootstrap, so we pass the project we have in hand.)
89
+ setPhase('done')
90
+ onReady({ project: p, issue: foundIssue, prefs: curPrefs })
91
+ return
92
+ }
93
+ setPhase('pref')
94
+ setStep(next)
95
+ } else {
96
+ setIssueId(undefined)
97
+ setIssue(null)
98
+ setPrefs(curPrefs)
99
+ setPhase('pref')
100
+ setStep('issue')
101
+ }
102
+ setStatusMsg('')
103
+ }
104
+
105
+ function computeStep(p: IssuePreference): PrefStep | null {
106
+ const done = new Set(p.done ?? [])
107
+ for (const s of STEP_ORDER) if (!done.has(s)) return s
108
+ return null
109
+ }
110
+
111
+ // ── project picker / creation ────────────────────────────────────────────
112
+ async function pickProject(p: Project) {
113
+ await bindCwdToProject(thisCwd, p.id)
114
+ await enterProject(p)
115
+ }
116
+ async function createProject(name: string, description: string) {
117
+ setStatusMsg('创建项目…')
118
+ try {
119
+ const p = await client.createProject({ name: name || '未命名项目', description, bindPath: thisCwd, defaultUseWorktree: false })
120
+ const list = await client.listProjects(); await saveProjectsCache(list); setProjects(list)
121
+ await bindCwdToProject(thisCwd, p.id)
122
+ await enterProject(p, list)
123
+ } catch (e: any) { setStatusMsg(`创建项目失败: ${e?.message ?? e}`) }
124
+ }
125
+
126
+ // ── issue picker / creation ──────────────────────────────────────────────
127
+ async function pickIssue(iss: Issue) {
128
+ const p = await setCwdIssue(thisCwd, iss.id, iss.title)
129
+ setIssue(iss); setIssueId(iss.id); setPrefs(p)
130
+ const next = computeStep(p)
131
+ setStep(next)
132
+ if (!next) finish(iss, p)
133
+ }
134
+ async function createIssue(name: string, useWt: boolean) {
135
+ if (!project) return
136
+ setStatusMsg('创建任务…')
137
+ try {
138
+ const iss = await client.createIssue(project.id, { title: name || '命令行任务', description: '由 TUI 创建', use_worktree: useWt })
139
+ setIssues(await client.listIssues(project.id, 'active'))
140
+ await pickIssue(iss)
141
+ } catch (e: any) { setStatusMsg(`创建任务失败: ${e?.message ?? e}`) }
142
+ }
143
+
144
+ // ── preference step completions ─────────────────────────────────────────
145
+ async function completeStep(stepKey: PrefStep, patch: Partial<IssuePreference>) {
146
+ if (!issueId) return
147
+ const merged = await updateIssuePreference(thisCwd, issueId, { ...patch, done: Array.from(new Set([...(prefs.done ?? []), stepKey])) })
148
+ setPrefs(merged)
149
+ const next = computeStep(merged)
150
+ setStep(next)
151
+ if (!next && issue) {
152
+ finish(issue, merged)
153
+ }
154
+ }
155
+
156
+ function finish(iss: Issue, p: IssuePreference) {
157
+ if (!project) return
158
+ setPhase('done')
159
+ onReady({ project, issue: iss, prefs: p })
160
+ }
161
+
162
+ // ── lazy-load lists for the active step ──────────────────────────────────
163
+ useEffect(() => {
164
+ if (phase !== 'pref' || !step) return
165
+ if (step === 'model' && !modelOpts.length) {
166
+ client.modelOptions().then(setModelOpts).catch(() => {})
167
+ client.defaultModel().then(r => setDefaultModel(r.model)).catch(() => {})
168
+ }
169
+ if (step === 'skills' && !skills.length && project) {
170
+ client.listSkills(project.id).then(setSkills).catch(() => {})
171
+ }
172
+ if (step === 'memories' && !memories.length && project) {
173
+ client.listMemories(project.id).then(setMemories).catch(() => {})
174
+ }
175
+ }, [phase, step, project])
176
+
177
+ // ── render ───────────────────────────────────────────────────────────────
178
+ if (phase === 'loading') {
179
+ return <Box paddingX={2} paddingY={1}><Text color="cyan">{statusMsg || '加载中…'}</Text></Box>
180
+ }
181
+ if (phase === 'project') {
182
+ return <ProjectPicker
183
+ cwd={thisCwd} projects={projects} statusMsg={statusMsg}
184
+ onPick={pickProject} onCreate={createProject} onQuit={onQuit} />
185
+ }
186
+ if (phase === 'done') {
187
+ return <Box paddingX={2}><Text color="green">准备就绪,进入对话…</Text></Box>
188
+ }
189
+ // phase === 'pref'
190
+ return <Box flexDirection="column" paddingX={2} paddingY={1}>
191
+ <Text color="gray">项目: <Text bold>{project?.name}</Text> · 当前路径: {thisCwd}</Text>
192
+ {statusMsg ? <Text color="yellow">{statusMsg}</Text> : null}
193
+ {step === 'issue'
194
+ ? <IssuePicker issues={issues} onPick={pickIssue} onCreate={createIssue} />
195
+ : null}
196
+ {step === 'model'
197
+ ? <ModelPicker options={modelOpts} defaultKey={defaultModel ?? prefs.model}
198
+ onSelect={key => completeStep('model', { model: key })} />
199
+ : null}
200
+ {step === 'language'
201
+ ? <Select
202
+ title="选择回复语言"
203
+ items={[{ label: '中文', value: 'zh' }, { label: 'English', value: 'en' }]}
204
+ onSelect={v => completeStep('language', { language: v as 'zh' | 'en' })} />
205
+ : null}
206
+ {step === 'skills'
207
+ ? <MultiPicker title={`选择启用的 Skill(默认全部启用,空格取消)`} items={toItems(skills)}
208
+ excluded={prefs.excluded_skill_ids}
209
+ onConfirm={excluded => completeStep('skills', { excluded_skill_ids: excluded })} />
210
+ : null}
211
+ {step === 'memories'
212
+ ? <MultiPicker title={`选择启用的 Memory(默认全部启用,空格取消)`} items={toItems(memories)}
213
+ excluded={prefs.excluded_memory_ids}
214
+ onConfirm={excluded => completeStep('memories', { excluded_memory_ids: excluded })} />
215
+ : null}
216
+ </Box>
217
+ }
218
+
219
+ function toItems(arr: { id: string; name: string; description?: string }[]): SelectItem[] {
220
+ return arr.map(s => ({ label: s.name, value: s.id, desc: s.description }))
221
+ }
222
+
223
+ // 把可能含换行的描述压成单行:换行 → 可见符号 ⏎,避免列表项跨行。
224
+ function flattenDesc(s?: string): string {
225
+ if (!s) return ''
226
+ return s.replace(/\s*\n\s*/g, ' ⏎ ').replace(/[ \t]+/g, ' ').trim()
227
+ }
228
+
229
+ // ── Project picker ───────────────────────────────────────────────────────────
230
+ function ProjectPicker({ cwd, projects, statusMsg, onPick, onCreate, onQuit }: {
231
+ cwd: string
232
+ projects: Project[]
233
+ statusMsg: string
234
+ onPick: (p: Project) => void
235
+ onCreate: (name: string, description: string) => void
236
+ onQuit?: () => void
237
+ }) {
238
+ const [mode, setMode] = useState<'list' | 'create'>('list')
239
+ const [name, setName] = useState('')
240
+
241
+ if (mode === 'create') {
242
+ return (
243
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
244
+ <Text bold color="cyan">创建新项目(绑定到当前路径)</Text>
245
+ <Text color="gray">{cwd}</Text>
246
+ <Box marginTop={1} flexDirection="column">
247
+ <Text color="cyan">项目名称 ←</Text>
248
+ <TextInput value={name} onChange={setName} focused placeholder="未命名项目"
249
+ onSubmit={() => onCreate(name, '')} onEscape={() => setMode('list')} />
250
+ </Box>
251
+ {statusMsg ? <Text color="yellow">{statusMsg}</Text> : null}
252
+ <Text color="gray">回车创建 · Esc 返回</Text>
253
+ </Box>
254
+ )
255
+ }
256
+
257
+ const items: SelectItem[] = [
258
+ { label: '➕ 创建新项目', value: '__create__', desc: '绑定到当前路径' },
259
+ ...projects.map(p => {
260
+ const desc = flattenDesc(p.description)
261
+ return { label: desc ? `${p.name} — ${desc}` : p.name, value: p.id }
262
+ }),
263
+ ]
264
+ return (
265
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
266
+ <Text bold color="cyan">选择当前路径的绑定项目</Text>
267
+ <Text color="gray">{cwd}</Text>
268
+ <Box marginTop={1}>
269
+ <Select items={items} onBack={onQuit} onSelect={v => v === '__create__' ? setMode('create') : onPick(projects.find(p => p.id === v)!)} />
270
+ </Box>
271
+ {statusMsg ? <Text color="yellow">{statusMsg}</Text> : null}
272
+ <Text color="gray">↑↓ 选择 · 回车确认 · Esc 退出</Text>
273
+ </Box>
274
+ )
275
+ }
276
+
277
+ // ── Issue picker ─────────────────────────────────────────────────────────────
278
+ function IssuePicker({ issues, onPick, onCreate }: {
279
+ issues: Issue[]
280
+ onPick: (i: Issue) => void
281
+ onCreate: (name: string, useWt: boolean) => void
282
+ }) {
283
+ const [mode, setMode] = useState<'list' | 'create-name' | 'create-wt'>('list')
284
+ const [name, setName] = useState('')
285
+
286
+ if (mode === 'create-name') {
287
+ return (
288
+ <Box flexDirection="column" paddingX={2}>
289
+ <Text bold color="cyan">创建新任务 · 第 1 步:名称</Text>
290
+ <TextInput value={name} onChange={setName} focused placeholder="命令行任务"
291
+ onSubmit={() => setMode('create-wt')} />
292
+ <Text color="gray">回车继续 · Esc 返回</Text>
293
+ </Box>
294
+ )
295
+ }
296
+ if (mode === 'create-wt') {
297
+ return (
298
+ <Box flexDirection="column" paddingX={2}>
299
+ <Text bold color="cyan">创建新任务 · 第 2 步:是否使用 git worktree?</Text>
300
+ <Select
301
+ items={[{ label: '否(默认)', value: 'no' }, { label: '是', value: 'yes' }]}
302
+ onSelect={v => onCreate(name, v === 'yes')} />
303
+ <Text color="gray">回车确认 · Esc 返回</Text>
304
+ </Box>
305
+ )
306
+ }
307
+ const items: SelectItem[] = [
308
+ { label: '➕ 创建新任务', value: '__create__' },
309
+ ...issues.map(i => ({ label: i.title, value: i.id, desc: i.description })),
310
+ ]
311
+ return (
312
+ <Box flexDirection="column">
313
+ <Text bold color="cyan">选择任务(Issue)</Text>
314
+ <Text color="gray">偏好设置将保存在所选任务内部</Text>
315
+ <Box marginTop={1}>
316
+ {issues.length === 0 && mode === 'list'
317
+ ? <Select items={[{ label: '➕ 创建新任务(尚无任务)', value: '__create__' }]} onSelect={() => setMode('create-name')} />
318
+ : <Select items={items} onSelect={v => v === '__create__' ? setMode('create-name') : onPick(issues.find(i => i.id === v)!)} />}
319
+ </Box>
320
+ </Box>
321
+ )
322
+ }
323
+
324
+ // ── Model picker ─────────────────────────────────────────────────────────────
325
+ function ModelPicker({ options, defaultKey, onSelect }: {
326
+ options: SessionModelOption[]
327
+ defaultKey?: string | null
328
+ onSelect: (key: string) => void
329
+ }) {
330
+ if (!options.length) return <Text color="gray">加载模型列表…</Text>
331
+ const items: SelectItem[] = options.map(o => ({
332
+ label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
333
+ value: o.key,
334
+ desc: o.sub,
335
+ }))
336
+ return (
337
+ <Box flexDirection="column">
338
+ <Text bold color="cyan">选择模型</Text>
339
+ <Select items={items} onSelect={onSelect} />
340
+ </Box>
341
+ )
342
+ }
343
+
344
+ // ── Multi picker (skills / memories, exclusion model) ────────────────────────
345
+ function MultiPicker({ title, items, excluded, onConfirm }: {
346
+ title: string
347
+ items: SelectItem[]
348
+ excluded: string[]
349
+ onConfirm: (excluded: string[]) => void
350
+ }) {
351
+ const [excl, setExcl] = useState<Set<string>>(new Set(excluded))
352
+ // Hooks must be called unconditionally — auto-confirm empty lists here.
353
+ useEffect(() => { if (items.length === 0) onConfirm([]) }, [items.length])
354
+ if (!items.length) return <Text color="gray">(无可用项,自动跳过…)</Text>
355
+ return (
356
+ <Box flexDirection="column">
357
+ <Text bold color="cyan">{title}</Text>
358
+ <Select
359
+ mode="multi"
360
+ items={items}
361
+ selected={items.filter(i => !excl.has(i.value)).map(i => i.value)}
362
+ onToggle={(v) => setExcl(prev => { const n = new Set(prev); n.has(v) ? n.delete(v) : n.add(v); return n })}
363
+ onConfirm={() => onConfirm(Array.from(excl))}
364
+ />
365
+ <Text color="gray">↑↓ 移动 · 空格 切换 · 回车 确认</Text>
366
+ </Box>
367
+ )
368
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * /resume picker — list the ~32 most recently active sessions in the current
3
+ * project (aggregated across its issues, ordered by last_active DESC), pick one
4
+ * to reconnect its SSE stream.
5
+ */
6
+ import React, { useEffect, useState } from 'react'
7
+ import { Box, Text } from 'ink'
8
+ import { Select } from './primitives.js'
9
+ import { MobiusClient } from '../api.js'
10
+ import type { Project, Session } from '../types.js'
11
+
12
+ function relativeTime(iso?: string): string {
13
+ if (!iso) return ''
14
+ const then = new Date(iso).getTime()
15
+ if (Number.isNaN(then)) return iso
16
+ const diff = Date.now() - then
17
+ const min = Math.floor(diff / 60000)
18
+ if (min < 1) return '刚刚'
19
+ if (min < 60) return `${min} 分钟前`
20
+ const hr = Math.floor(min / 60)
21
+ if (hr < 24) return `${hr} 小时前`
22
+ const day = Math.floor(hr / 24)
23
+ if (day < 30) return `${day} 天前`
24
+ return new Date(iso).toISOString().slice(0, 10)
25
+ }
26
+
27
+ export function ResumePicker({ client, project, onPick, onBack }: {
28
+ client: MobiusClient
29
+ project: Project
30
+ onPick: (sessionId: string) => void
31
+ onBack: () => void
32
+ }) {
33
+ const [sessions, setSessions] = useState<Session[] | null>(null)
34
+ const [err, setErr] = useState<string | null>(null)
35
+
36
+ useEffect(() => {
37
+ client.listProjectSessions(project.id, 32)
38
+ .then(setSessions)
39
+ .catch(e => setErr(e?.message ?? String(e)))
40
+ }, [client, project.id])
41
+
42
+ if (err) return <Box paddingX={2}><Text color="red">加载会话失败: {err}</Text></Box>
43
+ if (sessions === null) return <Box paddingX={2}><Text color="cyan">加载历史会话…</Text></Box>
44
+
45
+ const items = sessions.map(s => ({
46
+ label: `${s.name}${s.issue_title ? ` · ${s.issue_title}` : ''}`,
47
+ value: s.session_id,
48
+ desc: `${relativeTime(s.last_active)} · ${s.message_count ?? 0} 条消息 · 模型 ${s.model ?? '?'}`,
49
+ }))
50
+
51
+ return (
52
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
53
+ <Text bold color="cyan">恢复历史会话({project.name})</Text>
54
+ <Text color="gray">按活跃时间排序,最近 32 个</Text>
55
+ <Box marginTop={1}>
56
+ {items.length === 0
57
+ ? <Text color="gray">(暂无历史会话)</Text>
58
+ : <Select items={items} onSelect={onPick} onBack={onBack} />}
59
+ </Box>
60
+ {items.length > 0 ? <Text color="gray">↑↓ 选择 · 回车确认 · Esc 返回</Text> : null}
61
+ </Box>
62
+ )
63
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Reusable Ink primitives: TextInput (with inline block cursor + multi-line),
3
+ * Select (single-choice list + multi-choice with checkboxes), and a Spinner.
4
+ */
5
+ import React, { useEffect, useRef, useState } from 'react'
6
+ import { Box, Text, useInput, useStdout } from 'ink'
7
+
8
+ // ─── TextInput ───────────────────────────────────────────────────────────────
9
+ export interface TextInputProps {
10
+ value: string
11
+ onChange: (v: string) => void
12
+ onSubmit?: () => void
13
+ onArrowUp?: () => void
14
+ onArrowDown?: () => void
15
+ onEscape?: () => void
16
+ onTab?: () => void
17
+ placeholder?: string
18
+ focused?: boolean
19
+ mask?: boolean
20
+ prompt?: string
21
+ }
22
+
23
+ export function TextInput(props: TextInputProps) {
24
+ const { value, onChange } = props
25
+ const focused = props.focused !== false
26
+ const [cursor, setCursor] = useState(value.length)
27
+ const lastValueRef = useRef(value)
28
+
29
+ // When the value is changed externally (e.g. history navigation), park the
30
+ // cursor at the end. Edits performed below keep lastValueRef in sync so this
31
+ // effect only fires on true external changes.
32
+ useEffect(() => {
33
+ if (value !== lastValueRef.current) {
34
+ lastValueRef.current = value
35
+ setCursor(value.length)
36
+ }
37
+ }, [value])
38
+
39
+ function edit(next: string, nextCursor: number) {
40
+ lastValueRef.current = next
41
+ onChange(next)
42
+ setCursor(nextCursor)
43
+ }
44
+
45
+ useInput((input, key) => {
46
+ if (key.return) { props.onSubmit?.(); return }
47
+ if (key.upArrow) { props.onArrowUp?.(); return }
48
+ if (key.downArrow) { props.onArrowDown?.(); return }
49
+ if (key.escape) { props.onEscape?.(); return }
50
+ if (key.tab) { props.onTab?.(); return }
51
+ // Ink labels the \x7f that virtually every terminal's Backspace key emits
52
+ // as `key.delete` (see its parse-keypress.js TODO). Treat either signal as
53
+ // a backward delete — otherwise Backspace at the end of the input is a no-op.
54
+ if (key.backspace || key.delete || (key.ctrl && input === 'h')) {
55
+ if (cursor > 0) {
56
+ // delete word on Ctrl+W
57
+ if (key.ctrl && input === 'w') {
58
+ const before = value.slice(0, cursor)
59
+ const m = before.match(/\S+\s*$/)
60
+ const cut = m ? m[0].length : 0
61
+ edit(value.slice(0, cursor - cut) + value.slice(cursor), cursor - cut)
62
+ } else {
63
+ edit(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1)
64
+ }
65
+ }
66
+ return
67
+ }
68
+ if (key.leftArrow) { setCursor(c => Math.max(0, c - 1)); return }
69
+ if (key.rightArrow) { setCursor(c => Math.min(value.length, c + 1)); return }
70
+ if (key.ctrl && input === 'a') { setCursor(0); return }
71
+ if (key.ctrl && input === 'e') { setCursor(value.length); return }
72
+ if (key.ctrl && input === 'u') { edit('', 0); return }
73
+ if (key.ctrl && input === 'k') { edit(value.slice(0, cursor), cursor); return }
74
+ if (key.ctrl && input === 'j') { edit(value.slice(0, cursor) + '\n' + value.slice(cursor), cursor + 1); return } // newline
75
+ if (key.ctrl || key.meta) return
76
+ if (!input) return
77
+ edit(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length)
78
+ }, { isActive: focused })
79
+
80
+ const c = Math.min(cursor, value.length)
81
+ const display = props.mask ? '•'.repeat(value.length) : value
82
+ const dc = props.mask ? c : c
83
+ const lineStart = display.slice(0, dc).lastIndexOf('\n') + 1
84
+ const lineIdx = (display.slice(0, dc).match(/\n/g) ?? []).length
85
+ const lines = display.split('\n')
86
+ const curLine = lines[lineIdx] ?? ''
87
+ const col = dc - lineStart
88
+ const beforeCol = curLine.slice(0, col)
89
+ const atCol = curLine.slice(col, col + 1)
90
+ const afterCol = curLine.slice(col + 1)
91
+
92
+ if (!display && props.placeholder) {
93
+ return (
94
+ <Box>
95
+ {props.prompt ? <Text color="cyan">{props.prompt} </Text> : null}
96
+ {focused ? <Text backgroundColor="white" color="black"> </Text> : null}
97
+ <Text color="gray">{props.placeholder}</Text>
98
+ </Box>
99
+ )
100
+ }
101
+
102
+ // Keep inactive inputs visible without drawing a fake cursor. Previously
103
+ // every TextInput painted a white block even when its useInput hook was
104
+ // inactive, so multi-field forms appeared focused in two places at once.
105
+ if (!focused) {
106
+ return (
107
+ <Box>
108
+ {props.prompt ? <Text color="cyan">{props.prompt} </Text> : null}
109
+ <Text>{display || ' '}</Text>
110
+ </Box>
111
+ )
112
+ }
113
+
114
+ return (
115
+ <Box flexDirection="column">
116
+ <Box>
117
+ {props.prompt ? <Text color="cyan">{props.prompt} </Text> : null}
118
+ <Text>
119
+ {lines.map((ln, i) => {
120
+ if (i < lineIdx) return <Text key={i}>{ln || ' '}{'\n'}</Text>
121
+ if (i === lineIdx) {
122
+ return (
123
+ <Text key={i}>
124
+ {beforeCol}
125
+ <Text backgroundColor="white" color="black">{atCol || ' '}</Text>
126
+ {afterCol}
127
+ {i < lines.length - 1 ? '\n' : ''}
128
+ </Text>
129
+ )
130
+ }
131
+ return <Text key={i}>{'\n'}{ln || ' '}</Text>
132
+ })}
133
+ </Text>
134
+ </Box>
135
+ </Box>
136
+ )
137
+ }
138
+
139
+ // ─── Select ──────────────────────────────────────────────────────────────────
140
+ export interface SelectItem {
141
+ label: string
142
+ value: string
143
+ desc?: string
144
+ }
145
+
146
+ export interface SelectProps {
147
+ items: SelectItem[]
148
+ mode?: 'single' | 'multi'
149
+ selected?: string | string[] // single value (single-mode) or selected values (multi)
150
+ onSelect?: (value: string) => void // single-mode
151
+ onToggle?: (value: string) => void // multi-mode: space toggles
152
+ onConfirm?: (selected: string[]) => void // multi-mode: Enter confirms
153
+ onBack?: () => void
154
+ focused?: boolean
155
+ title?: string
156
+ maxVisible?: number // cap rendered rows so long lists never overflow the terminal
157
+ }
158
+
159
+ export function Select(props: SelectProps) {
160
+ const mode = props.mode ?? 'single'
161
+ const [active, setActive] = useState(0)
162
+ const items = props.items
163
+ const selectedSet = new Set<string>(mode === 'multi' ? (props.selected as string[]) ?? [] : [])
164
+ const { stdout } = useStdout()
165
+
166
+ useEffect(() => { setActive(a => Math.min(a, Math.max(0, items.length - 1))) }, [items.length])
167
+
168
+ useInput((input, key) => {
169
+ if (!items.length) return
170
+ if (key.upArrow) { setActive(a => (a - 1 + items.length) % items.length); return }
171
+ if (key.downArrow) { setActive(a => (a + 1) % items.length); return }
172
+ if (mode === 'single') {
173
+ if (key.return) { props.onSelect?.(items[active].value); return }
174
+ } else {
175
+ if (key.return) { props.onConfirm?.(Array.from(selectedSet)); return }
176
+ if (input === ' ') { props.onToggle?.(items[active].value); return }
177
+ }
178
+ if (key.escape) { props.onBack?.(); return }
179
+ }, { isActive: props.focused !== false })
180
+
181
+ // viewport: keep the active item on screen. Without this a long list renders
182
+ // every row and pushes the lower items (and the rest of the UI) past the
183
+ // terminal bottom. We render a sliding window around `active` plus a
184
+ // "↑/↓ 还有 N 项" hint for the hidden tails.
185
+ const total = items.length
186
+ const rows = stdout?.rows ?? 24
187
+ const maxVisible = props.maxVisible ?? Math.max(3, rows - 8)
188
+ let start = 0
189
+ if (total > maxVisible) {
190
+ const half = Math.floor(maxVisible / 2)
191
+ start = Math.max(0, active - half)
192
+ start = Math.min(start, total - maxVisible)
193
+ }
194
+ const end = Math.min(total, start + maxVisible)
195
+ const hiddenAbove = start
196
+ const hiddenBelow = total - end
197
+
198
+ return (
199
+ <Box flexDirection="column">
200
+ {props.title ? <Text color="cyan" bold>{props.title}</Text> : null}
201
+ {items.length === 0 ? <Text color="gray">(无项目)</Text> : null}
202
+ {hiddenAbove > 0 ? <Text color="gray"> ↑ 还有 {hiddenAbove} 项</Text> : null}
203
+ {items.slice(start, end).map((it, i) => {
204
+ const realIdx = start + i
205
+ const isActive = realIdx === active
206
+ const checked = mode === 'multi' ? selectedSet.has(it.value) : false
207
+ const marker = mode === 'multi' ? (checked ? '☑' : '☐') : isActive ? '❯' : ' '
208
+ return (
209
+ <Box key={it.value} flexDirection="column">
210
+ <Text
211
+ color={isActive ? 'black' : undefined}
212
+ backgroundColor={isActive ? 'cyan' : undefined}
213
+ bold={isActive}
214
+ wrap="truncate-end"
215
+ >
216
+ {marker} {it.label}
217
+ </Text>
218
+ {isActive && it.desc ? <Text color="gray" wrap="truncate-end"> {it.desc}</Text> : null}
219
+ </Box>
220
+ )
221
+ })}
222
+ {hiddenBelow > 0 ? <Text color="gray"> ↓ 还有 {hiddenBelow} 项</Text> : null}
223
+ </Box>
224
+ )
225
+ }
226
+
227
+ // ─── Spinner ─────────────────────────────────────────────────────────────────
228
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
229
+ export function Spinner({ label }: { label?: string }) {
230
+ const [i, setI] = useState(0)
231
+ useEffect(() => {
232
+ const id = setInterval(() => setI(x => (x + 1) % FRAMES.length), 80)
233
+ return () => clearInterval(id)
234
+ }, [])
235
+ return (
236
+ <Text>
237
+ <Text color="cyan">{FRAMES[i]}</Text>
238
+ {label ? ` ${label}` : ''}
239
+ </Text>
240
+ )
241
+ }