@mobius-os/mobius 0.3.20 → 0.3.24
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 +1 -1
- package/src/App.tsx +6 -5
- package/src/components/Chat.tsx +79 -19
- package/src/components/ConfigFlow.tsx +263 -6
- package/src/components/PrepScreen.tsx +8 -1
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -121,13 +121,14 @@ export function App() {
|
|
|
121
121
|
setRoute('chat')
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
// /model
|
|
125
|
-
// `ready`, so it folds the
|
|
126
|
-
// session (resumeSessionId = the eagerly
|
|
124
|
+
// /model or /config: swap task+model (and optionally project for /config)
|
|
125
|
+
// and start a brand-new session. App owns `ready`, so it folds the result in
|
|
126
|
+
// and remounts Chat on the fresh session (resumeSessionId = the eagerly
|
|
127
|
+
// created session).
|
|
127
128
|
function onReconfigure(result: ConfigResult) {
|
|
128
129
|
if (!ready || !client) return
|
|
129
|
-
if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] reconfigure', result.issue.id, result.prefs.model, result.sessionId)
|
|
130
|
-
setReady({ project: ready.project, issue: result.issue, prefs: result.prefs })
|
|
130
|
+
if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] reconfigure', result.project?.id, result.issue.id, result.prefs.model, result.sessionId)
|
|
131
|
+
setReady({ project: result.project || ready.project, issue: result.issue, prefs: result.prefs })
|
|
131
132
|
setResumeSessionId(result.sessionId)
|
|
132
133
|
setChatKey(k => k + 1)
|
|
133
134
|
setRoute('chat')
|
package/src/components/Chat.tsx
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from '../lib/screen-text.js'
|
|
22
22
|
import type { ReadyState } from './PrepScreen.js'
|
|
23
23
|
import type { AnyEntry } from '../types.js'
|
|
24
|
-
import { ConfigFlow, type ConfigResult } from './ConfigFlow.js'
|
|
24
|
+
import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
|
|
25
25
|
import type { AimuxStatus } from '../aimux.js'
|
|
26
26
|
import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
|
|
27
27
|
import { isEscapeKeypress, isMouseInput, useMouseEvents } from './primitives.js'
|
|
@@ -55,7 +55,7 @@ const SLASH_COMMANDS = [
|
|
|
55
55
|
{ cmd: '/clear', desc: '清空当前对话,开启新会话' },
|
|
56
56
|
{ cmd: '/resume', desc: '恢复一个历史会话' },
|
|
57
57
|
{ cmd: '/model', desc: '更换模型并开启新会话(保留当前任务)' },
|
|
58
|
-
{ cmd: '/config', desc: '
|
|
58
|
+
{ cmd: '/config', desc: '重新选择项目、任务和模型' },
|
|
59
59
|
{ cmd: '/help', desc: '显示帮助' },
|
|
60
60
|
{ cmd: '/quit', desc: '退出 TUI' },
|
|
61
61
|
]
|
|
@@ -67,11 +67,12 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
67
67
|
const [composerRows, setComposerRows] = useState(DEFAULT_COMPOSER_ROWS)
|
|
68
68
|
const [modelLabel, setModelLabel] = useState<string | null>(null)
|
|
69
69
|
const [configOpen, setConfigOpen] = useState(false)
|
|
70
|
+
const [reconfigOpen, setReconfigOpen] = useState(false)
|
|
70
71
|
// Ink's useInput keeps whatever handler was registered at subscription time;
|
|
71
72
|
// reading mutable refs (updated every render) keeps the callback from acting
|
|
72
73
|
// on a stale `configOpen`/sessionId closure after the config flow opens.
|
|
73
|
-
const handlerRef = useRef<{ configOpen: boolean; sessionId: string | null }>({ configOpen: false, sessionId: null })
|
|
74
|
-
handlerRef.current = { configOpen, sessionId: chat.sessionId }
|
|
74
|
+
const handlerRef = useRef<{ configOpen: boolean; reconfigOpen: boolean; sessionId: string | null }>({ configOpen: false, reconfigOpen: false, sessionId: null })
|
|
75
|
+
handlerRef.current = { configOpen, reconfigOpen, sessionId: chat.sessionId }
|
|
75
76
|
const terminal = useTerminalSize()
|
|
76
77
|
|
|
77
78
|
const runSlash = useCallback((raw: string) => {
|
|
@@ -80,7 +81,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
80
81
|
case '/clear': onClear(); return true
|
|
81
82
|
case '/resume': onResume(); return true
|
|
82
83
|
case '/help': setShowHelp(s => !s); return true
|
|
83
|
-
case '/model':
|
|
84
|
+
case '/model': setConfigOpen(true); return true
|
|
85
|
+
case '/config': setReconfigOpen(true); return true
|
|
84
86
|
case '/quit': case '/exit': onQuit(); return true
|
|
85
87
|
default: return false
|
|
86
88
|
}
|
|
@@ -148,12 +150,13 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
148
150
|
const modelDisplay = modelLabel ?? ready.prefs.model ?? 'default'
|
|
149
151
|
|
|
150
152
|
useInput((_input, key) => {
|
|
151
|
-
// While
|
|
152
|
-
// cancel is reliable even mid-list-loading (a per-component
|
|
153
|
-
// could be unmounted by the loading→loaded transition and drop
|
|
154
|
-
// configOpen/sessionId are read from handlerRef
|
|
155
|
-
// the originally-registered callback and would
|
|
156
|
-
|
|
153
|
+
// While a config/reconfig flow is open, this ChatScreen-level handler owns
|
|
154
|
+
// Esc so cancel is reliable even mid-list-loading (a per-component
|
|
155
|
+
// EscToCancel could be unmounted by the loading→loaded transition and drop
|
|
156
|
+
// the keypress). configOpen/reconfigOpen/sessionId are read from handlerRef
|
|
157
|
+
// (see above) because Ink keeps the originally-registered callback and would
|
|
158
|
+
// otherwise see a stale closure.
|
|
159
|
+
if (handlerRef.current.configOpen || handlerRef.current.reconfigOpen) {
|
|
157
160
|
if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
|
|
158
161
|
return
|
|
159
162
|
}
|
|
@@ -262,6 +265,23 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
262
265
|
)
|
|
263
266
|
}
|
|
264
267
|
|
|
268
|
+
if (reconfigOpen) {
|
|
269
|
+
return (
|
|
270
|
+
<Box
|
|
271
|
+
flexDirection="column"
|
|
272
|
+
width={terminal.isTty ? terminal.columns : undefined}
|
|
273
|
+
height={terminal.isTty ? viewportRows : undefined}
|
|
274
|
+
paddingX={1}
|
|
275
|
+
overflowY="hidden"
|
|
276
|
+
>
|
|
277
|
+
<ReconfigFlow
|
|
278
|
+
client={client}
|
|
279
|
+
onDone={(result) => onReconfigure(result)}
|
|
280
|
+
/>
|
|
281
|
+
</Box>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
265
285
|
return (
|
|
266
286
|
<Box
|
|
267
287
|
flexDirection="column"
|
|
@@ -282,7 +302,14 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
282
302
|
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {olderHint}</Text></Box>
|
|
283
303
|
: null}
|
|
284
304
|
|
|
285
|
-
<Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome ? 'flex-start' : 'flex-end'} overflowY="hidden">
|
|
305
|
+
<Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome || fitted.hiddenOlder > 0 ? 'flex-start' : 'flex-end'} overflowY="hidden">
|
|
306
|
+
{fitted.peekLines.length > 0
|
|
307
|
+
? <Box width="100%" flexShrink={0} flexDirection="column">
|
|
308
|
+
{fitted.peekLines.map((line, index) => (
|
|
309
|
+
<Text key={`peek-${index}`} dimColor wrap="truncate-end">{index === 0 ? ' ⋯ ' : ' '}{line}</Text>
|
|
310
|
+
))}
|
|
311
|
+
</Box>
|
|
312
|
+
: null}
|
|
286
313
|
{fitted.entries.map((entry, index) => {
|
|
287
314
|
const entrySel = selMap?.get(index)
|
|
288
315
|
const key = entry.__id ?? `entry-${fitted.startIndex + index}`
|
|
@@ -1144,22 +1171,55 @@ function entryRows(entry: AnyEntry, columns: number): number {
|
|
|
1144
1171
|
|
|
1145
1172
|
function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
|
|
1146
1173
|
entries: AnyEntry[]
|
|
1174
|
+
/** Tail rows of the next older entry, used to fill spare space above the viewport. */
|
|
1175
|
+
peekLines: string[]
|
|
1147
1176
|
hiddenOlder: number
|
|
1148
1177
|
hiddenRecent: number
|
|
1149
1178
|
startIndex: number
|
|
1150
1179
|
} {
|
|
1151
1180
|
const tail = Math.max(0, entries.length - scrollBack)
|
|
1152
1181
|
const available = tail === 0 ? [] : entries.slice(0, tail)
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1182
|
+
const renderedRows = available.map((entry) => entryScreenLines(viewsForEntry(entry), columns))
|
|
1183
|
+
const fit = (budget: number) => {
|
|
1184
|
+
let rows = 0
|
|
1185
|
+
let first = available.length
|
|
1186
|
+
for (let index = available.length - 1; index >= 0; index--) {
|
|
1187
|
+
const nextRows = renderedRows[index].length
|
|
1188
|
+
if (first < available.length && rows + nextRows > budget) break
|
|
1189
|
+
rows += nextRows
|
|
1190
|
+
first = index
|
|
1191
|
+
}
|
|
1192
|
+
return { first, rows }
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const base = fit(rowBudget)
|
|
1196
|
+
let fitted = base
|
|
1197
|
+
let first = fitted.first
|
|
1198
|
+
let peekLines: string[] = []
|
|
1199
|
+
// When older history exists, guarantee at least one row for the tail of the
|
|
1200
|
+
// next older message. If complete entries exactly consume the budget, refit
|
|
1201
|
+
// them with one fewer row; only the oldest complete entry can drop out, while
|
|
1202
|
+
// the latest content remains visible. A single oversized entry keeps its
|
|
1203
|
+
// original rendering because it cannot safely donate a row.
|
|
1204
|
+
if (first > 0 && fitted.rows <= rowBudget) {
|
|
1205
|
+
if (fitted.rows === rowBudget && rowBudget > 1) {
|
|
1206
|
+
const reduced = fit(rowBudget - 1)
|
|
1207
|
+
if (reduced.first > 0 && reduced.rows <= rowBudget - 1) {
|
|
1208
|
+
fitted = reduced
|
|
1209
|
+
first = reduced.first
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
const olderLines = renderedRows[first - 1].slice()
|
|
1213
|
+
while (olderLines.length > 0 && !olderLines[0].trim()) olderLines.shift()
|
|
1214
|
+
while (olderLines.length > 0 && !olderLines[olderLines.length - 1].trim()) olderLines.pop()
|
|
1215
|
+
const spare = rowBudget - fitted.rows
|
|
1216
|
+
if (spare > 0 && olderLines.length > 0) {
|
|
1217
|
+
peekLines = olderLines.slice(-spare)
|
|
1218
|
+
}
|
|
1160
1219
|
}
|
|
1161
1220
|
return {
|
|
1162
1221
|
entries: available.slice(first),
|
|
1222
|
+
peekLines,
|
|
1163
1223
|
hiddenOlder: first,
|
|
1164
1224
|
hiddenRecent: entries.length - tail,
|
|
1165
1225
|
startIndex: first,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /model
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* /model flow — pick a model, then create a brand-new session in the CURRENT
|
|
3
|
+
* task (Issue) with that model. Launched from inside the chat; Esc at any point
|
|
4
|
+
* before the session is created cancels back to the conversation untouched.
|
|
5
5
|
*
|
|
6
6
|
* The active Issue is intentionally NOT changed: /model only swaps the model and
|
|
7
7
|
* starts a fresh session, keeping the current project/task context. Esc-cancel is
|
|
@@ -12,16 +12,27 @@
|
|
|
12
12
|
* updateIssuePreference — persist the chosen model on the active issue
|
|
13
13
|
* The session body mirrors useChat.ensureSession() so the pc_client_metadata
|
|
14
14
|
* (is_tui, aimux_id, local_path) matches lazily-created sessions exactly.
|
|
15
|
+
*
|
|
16
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
* /config flow (ReconfigFlow) — full reconfiguration: (1) project → (2) issue →
|
|
18
|
+
* (3) model, then create a brand-new session. Unlike /model, this CAN change the
|
|
19
|
+
* active project and issue. Project/issue choices are persisted (bindCwdToProject,
|
|
20
|
+
* setCwdIssue, updateIssuePreference) so they become the new defaults.
|
|
15
21
|
*/
|
|
16
22
|
import React, { useEffect, useRef, useState } from 'react'
|
|
17
23
|
import { Box, Text } from 'ink'
|
|
18
|
-
import { Select, Spinner } from './primitives.js'
|
|
24
|
+
import { Select, TextInput, Spinner, type SelectItem } from './primitives.js'
|
|
19
25
|
import { MobiusClient } from '../api.js'
|
|
20
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
bindCwdToProject, cwd, getCwdPreference, loadDir2Project, loadProjectsCache,
|
|
28
|
+
saveProjectsCache, setCwdIssue, updateIssuePreference, type IssuePreference,
|
|
29
|
+
} from '../config.js'
|
|
21
30
|
import { tuiAimuxIdentifier } from '../aimux.js'
|
|
22
|
-
import type { Issue, SessionModelOption } from '../types.js'
|
|
31
|
+
import type { Issue, Project, SessionModelOption } from '../types.js'
|
|
23
32
|
|
|
24
33
|
export interface ConfigResult {
|
|
34
|
+
/** If set, the project was also changed (from /config full reflow). */
|
|
35
|
+
project?: Project
|
|
25
36
|
issue: Issue
|
|
26
37
|
prefs: IssuePreference
|
|
27
38
|
sessionId: string
|
|
@@ -118,3 +129,249 @@ export function ConfigFlow({ client, issue, onDone }: {
|
|
|
118
129
|
</Box>
|
|
119
130
|
)
|
|
120
131
|
}
|
|
132
|
+
|
|
133
|
+
// ── /config: full reconfigure (project → issue → model) ─────────────────────
|
|
134
|
+
|
|
135
|
+
type ReconfigStep = 'projects' | 'issues' | 'models' | 'creating'
|
|
136
|
+
|
|
137
|
+
export function ReconfigFlow({ client, onDone }: {
|
|
138
|
+
client: MobiusClient
|
|
139
|
+
onDone: (r: ConfigResult) => void
|
|
140
|
+
}) {
|
|
141
|
+
const [step, setStep] = useState<ReconfigStep>('projects')
|
|
142
|
+
const [projects, setProjects] = useState<Project[] | null>(null)
|
|
143
|
+
const [project, setProject] = useState<Project | null>(null)
|
|
144
|
+
const [issues, setIssues] = useState<Issue[] | null>(null)
|
|
145
|
+
const [issue, setIssue] = useState<Issue | null>(null)
|
|
146
|
+
const [models, setModels] = useState<SessionModelOption[] | null>(null)
|
|
147
|
+
const [defaultKey, setDefaultKey] = useState<string | null>(null)
|
|
148
|
+
const [status, setStatus] = useState('')
|
|
149
|
+
const [createName, setCreateName] = useState('')
|
|
150
|
+
const [createMode, setCreateMode] = useState<'project' | 'issue' | null>(null)
|
|
151
|
+
const doneRef = useRef(false)
|
|
152
|
+
const thisCwd = cwd()
|
|
153
|
+
|
|
154
|
+
useEffect(() => () => { doneRef.current = true }, [])
|
|
155
|
+
|
|
156
|
+
// Load projects on mount.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
;(async () => {
|
|
159
|
+
let list = await loadProjectsCache()
|
|
160
|
+
try { list = await client.listProjects(); await saveProjectsCache(list) } catch { /* use cache */ }
|
|
161
|
+
if (doneRef.current) return
|
|
162
|
+
setProjects(list)
|
|
163
|
+
})().catch(e => { if (!doneRef.current) setStatus(`加载项目失败: ${e?.message ?? e}`) })
|
|
164
|
+
}, [client])
|
|
165
|
+
|
|
166
|
+
// Load issues when a project is picked.
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!project || step !== 'issues') return
|
|
169
|
+
;(async () => {
|
|
170
|
+
try {
|
|
171
|
+
const iss = await client.listIssues(project.id, 'active')
|
|
172
|
+
if (doneRef.current) return
|
|
173
|
+
setIssues(iss)
|
|
174
|
+
} catch (e: any) {
|
|
175
|
+
if (!doneRef.current) setStatus(`加载任务失败: ${e?.message ?? e}`)
|
|
176
|
+
}
|
|
177
|
+
})()
|
|
178
|
+
}, [project, step, client])
|
|
179
|
+
|
|
180
|
+
// Load models when we reach the model step.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (step !== 'models') return
|
|
183
|
+
Promise.all([
|
|
184
|
+
client.modelOptions().catch(() => [] as SessionModelOption[]),
|
|
185
|
+
client.defaultModel().then(r => r.model).catch(() => null),
|
|
186
|
+
]).then(([opts, def]) => {
|
|
187
|
+
if (doneRef.current) return
|
|
188
|
+
setModels(opts)
|
|
189
|
+
setDefaultKey(def)
|
|
190
|
+
})
|
|
191
|
+
}, [step, client])
|
|
192
|
+
|
|
193
|
+
// ── project ───────────────────────────────────────────────────────────────
|
|
194
|
+
async function pickProject(p: Project) {
|
|
195
|
+
await bindCwdToProject(thisCwd, p.id)
|
|
196
|
+
if (doneRef.current) return
|
|
197
|
+
setProject(p)
|
|
198
|
+
setIssues(null)
|
|
199
|
+
setStep('issues')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function createProject(name: string) {
|
|
203
|
+
setCreateMode(null)
|
|
204
|
+
setStatus('创建项目…')
|
|
205
|
+
try {
|
|
206
|
+
const safeDir = '/' + (name || '未命名项目').replace(/[^a-zA-Z0-9一-鿿_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 64) || 'project'
|
|
207
|
+
const p = await client.createProject({ name: name || '未命名项目', description: '', bindPath: safeDir, defaultUseWorktree: false })
|
|
208
|
+
const list = await client.listProjects(); await saveProjectsCache(list)
|
|
209
|
+
if (doneRef.current) return
|
|
210
|
+
setProjects(list)
|
|
211
|
+
await bindCwdToProject(thisCwd, p.id)
|
|
212
|
+
setProject(p)
|
|
213
|
+
setIssues(null)
|
|
214
|
+
setStep('issues')
|
|
215
|
+
setStatus('')
|
|
216
|
+
} catch (e: any) { if (!doneRef.current) setStatus(`创建项目失败: ${e?.message ?? e}`) }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── issue ─────────────────────────────────────────────────────────────────
|
|
220
|
+
async function pickIssue(iss: Issue) {
|
|
221
|
+
await setCwdIssue(thisCwd, iss.id, iss.title)
|
|
222
|
+
if (doneRef.current) return
|
|
223
|
+
setIssue(iss)
|
|
224
|
+
setStep('models')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function createIssue(name: string) {
|
|
228
|
+
if (!project) return
|
|
229
|
+
setCreateMode(null)
|
|
230
|
+
setStatus('创建任务…')
|
|
231
|
+
try {
|
|
232
|
+
const iss = await client.createIssue(project.id, { title: name || '命令行任务', description: '由 TUI 创建', use_worktree: false })
|
|
233
|
+
const refreshed = await client.listIssues(project.id, 'active')
|
|
234
|
+
if (doneRef.current) return
|
|
235
|
+
setIssues(refreshed)
|
|
236
|
+
await pickIssue(iss)
|
|
237
|
+
setStatus('')
|
|
238
|
+
} catch (e: any) { if (!doneRef.current) setStatus(`创建任务失败: ${e?.message ?? e}`) }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── model → session ───────────────────────────────────────────────────────
|
|
242
|
+
async function pickModel(model: string) {
|
|
243
|
+
if (!project || !issue) return
|
|
244
|
+
setStep('creating')
|
|
245
|
+
try {
|
|
246
|
+
const prefs = await updateIssuePreference(thisCwd, issue.id, { model })
|
|
247
|
+
if (doneRef.current) return
|
|
248
|
+
const s = await client.createSession(issue.id, {
|
|
249
|
+
name: `TUI ${new Date().toISOString().slice(5, 16).replace('T', ' ')}`,
|
|
250
|
+
model,
|
|
251
|
+
language: prefs.language,
|
|
252
|
+
excluded_skill_ids: prefs.excluded_skill_ids,
|
|
253
|
+
excluded_memory_ids: prefs.excluded_memory_ids,
|
|
254
|
+
pc_client_metadata: {
|
|
255
|
+
work_mode: 'pc',
|
|
256
|
+
aimux_id: tuiAimuxIdentifier(),
|
|
257
|
+
local_path: process.cwd(),
|
|
258
|
+
is_tui: true,
|
|
259
|
+
add_remote_aimux_mcp: true,
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
if (doneRef.current) return
|
|
263
|
+
onDone({ project, issue, prefs, sessionId: s.session_id })
|
|
264
|
+
} catch (e: any) {
|
|
265
|
+
if (doneRef.current) return
|
|
266
|
+
setStatus(`创建新会话失败: ${e?.message ?? e}`)
|
|
267
|
+
setStep('models')
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── render ────────────────────────────────────────────────────────────────
|
|
272
|
+
if (step === 'creating') {
|
|
273
|
+
return (
|
|
274
|
+
<Box paddingX={2} paddingY={1}>
|
|
275
|
+
<Spinner label="正在创建新会话…" />
|
|
276
|
+
</Box>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (createMode === 'project') {
|
|
281
|
+
return (
|
|
282
|
+
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
283
|
+
<Text bold color="cyan">创建新项目</Text>
|
|
284
|
+
<Text color="gray">{thisCwd}</Text>
|
|
285
|
+
<Box marginTop={1} flexDirection="column">
|
|
286
|
+
<Text color="cyan">项目名称 ←</Text>
|
|
287
|
+
<TextInput value={createName} onChange={setCreateName} focused placeholder="未命名项目"
|
|
288
|
+
onSubmit={() => createProject(createName)} onEscape={() => { setCreateMode(null); setCreateName('') }} />
|
|
289
|
+
</Box>
|
|
290
|
+
{status ? <Text color="yellow">{status}</Text> : null}
|
|
291
|
+
<Text color="gray">回车创建 · Esc 返回</Text>
|
|
292
|
+
</Box>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (createMode === 'issue') {
|
|
297
|
+
return (
|
|
298
|
+
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
299
|
+
<Text bold color="cyan">创建新任务</Text>
|
|
300
|
+
<Text color="gray">项目: {project?.name}</Text>
|
|
301
|
+
<TextInput value={createName} onChange={setCreateName} focused placeholder="命令行任务"
|
|
302
|
+
onSubmit={() => createIssue(createName)} onEscape={() => { setCreateMode(null); setCreateName('') }} />
|
|
303
|
+
{status ? <Text color="yellow">{status}</Text> : null}
|
|
304
|
+
<Text color="gray">回车创建 · Esc 返回</Text>
|
|
305
|
+
</Box>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
311
|
+
<Text bold color="cyan">重新配置</Text>
|
|
312
|
+
{status ? <Text color="yellow">{status}</Text> : null}
|
|
313
|
+
|
|
314
|
+
{step === 'projects' ? (
|
|
315
|
+
<Box flexDirection="column">
|
|
316
|
+
<Text bold color="cyan">选择项目</Text>
|
|
317
|
+
<Box marginTop={1}>
|
|
318
|
+
{projects === null
|
|
319
|
+
? <Text color="cyan">加载项目列表…</Text>
|
|
320
|
+
: <Select
|
|
321
|
+
items={[
|
|
322
|
+
{ label: '➕ 创建新项目', value: '__create__' },
|
|
323
|
+
...projects.map(p => ({ label: p.name, value: p.id, desc: p.description })),
|
|
324
|
+
]}
|
|
325
|
+
onSelect={v => v === '__create__' ? setCreateMode('project') : pickProject(projects!.find(p => p.id === v)!)}
|
|
326
|
+
/>}
|
|
327
|
+
</Box>
|
|
328
|
+
<Text color="gray">↑↓ 选择 · 回车确认 · Esc 取消</Text>
|
|
329
|
+
</Box>
|
|
330
|
+
) : null}
|
|
331
|
+
|
|
332
|
+
{step === 'issues' ? (
|
|
333
|
+
<Box flexDirection="column">
|
|
334
|
+
<Text bold color="cyan">选择任务</Text>
|
|
335
|
+
<Text color="gray">项目: {project?.name}</Text>
|
|
336
|
+
<Box marginTop={1}>
|
|
337
|
+
{issues === null
|
|
338
|
+
? <Text color="cyan">加载任务列表…</Text>
|
|
339
|
+
: issues.length === 0
|
|
340
|
+
? <Select
|
|
341
|
+
items={[{ label: '➕ 创建新任务(尚无任务)', value: '__create__' }]}
|
|
342
|
+
onSelect={() => setCreateMode('issue')} />
|
|
343
|
+
: <Select
|
|
344
|
+
items={[
|
|
345
|
+
{ label: '➕ 创建新任务', value: '__create__' },
|
|
346
|
+
...issues.map(i => ({ label: i.title, value: i.id, desc: i.description })),
|
|
347
|
+
]}
|
|
348
|
+
onSelect={v => v === '__create__' ? setCreateMode('issue') : pickIssue(issues!.find(i => i.id === v)!)} />}
|
|
349
|
+
</Box>
|
|
350
|
+
<Text color="gray">↑↓ 选择 · 回车确认 · Esc 取消</Text>
|
|
351
|
+
</Box>
|
|
352
|
+
) : null}
|
|
353
|
+
|
|
354
|
+
{step === 'models' ? (
|
|
355
|
+
<Box flexDirection="column">
|
|
356
|
+
<Text bold color="cyan">选择模型</Text>
|
|
357
|
+
<Text color="gray">项目: {project?.name} · 任务: {issue?.title}</Text>
|
|
358
|
+
<Box marginTop={1}>
|
|
359
|
+
{models === null
|
|
360
|
+
? <Text color="cyan">加载模型列表…</Text>
|
|
361
|
+
: models.length === 0
|
|
362
|
+
? <Text color="gray">(无可用模型)</Text>
|
|
363
|
+
: <Select
|
|
364
|
+
items={models.map(o => ({
|
|
365
|
+
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
366
|
+
value: o.key,
|
|
367
|
+
desc: o.sub,
|
|
368
|
+
}))}
|
|
369
|
+
onSelect={key => void pickModel(key)}
|
|
370
|
+
/>}
|
|
371
|
+
</Box>
|
|
372
|
+
<Text color="gray">↑↓ 选择 · 回车确认 · Esc 取消</Text>
|
|
373
|
+
</Box>
|
|
374
|
+
) : null}
|
|
375
|
+
</Box>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
@@ -116,7 +116,14 @@ export function PrepScreen({ client, onReady, onQuit }: {
|
|
|
116
116
|
async function createProject(name: string, description: string) {
|
|
117
117
|
setStatusMsg('创建项目…')
|
|
118
118
|
try {
|
|
119
|
-
|
|
119
|
+
// bindPath 是 mobius 服务器上的工作目录,不应使用 TUI 客户端的 process.cwd()。
|
|
120
|
+
// TUI 可能运行在不同机器/OS 上(尤其是通过 aimux 连接时),
|
|
121
|
+
// 客户端 cwd 对服务器无意义(Windows 路径如 C:\Users\... 在 Linux 上会被误解析为相对路径)。
|
|
122
|
+
// 用项目名+随机后缀生成唯一服务器端子目录,由服务器 resolveBindPath 拼到用户 work_dir 下。
|
|
123
|
+
const slug = (name || '未命名项目').replace(/[^a-zA-Z0-9一-鿿_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 56) || 'project'
|
|
124
|
+
const suffix = require('crypto').randomBytes(3).toString('hex')
|
|
125
|
+
const safeDir = `/${slug}-${suffix}`
|
|
126
|
+
const p = await client.createProject({ name: name || '未命名项目', description, bindPath: safeDir, defaultUseWorktree: false })
|
|
120
127
|
const list = await client.listProjects(); await saveProjectsCache(list); setProjects(list)
|
|
121
128
|
await bindCwdToProject(thisCwd, p.id)
|
|
122
129
|
await enterProject(p, list)
|