@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.
- package/README.md +174 -0
- package/bin/mobius-tui.js +12 -0
- package/package.json +29 -0
- package/src/App.tsx +145 -0
- package/src/aimux.ts +294 -0
- package/src/api.ts +206 -0
- package/src/components/AimuxStatus.tsx +45 -0
- package/src/components/Chat.tsx +519 -0
- package/src/components/Login.tsx +88 -0
- package/src/components/PrepScreen.tsx +368 -0
- package/src/components/ResumePicker.tsx +63 -0
- package/src/components/primitives.tsx +241 -0
- package/src/config.ts +159 -0
- package/src/hooks/useChat.ts +332 -0
- package/src/lib/entry-view.ts +351 -0
- package/src/main.tsx +13 -0
- package/src/markdown.ts +160 -0
- package/src/sse.ts +126 -0
- package/src/types.ts +216 -0
|
@@ -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
|
+
}
|