@mobius-os/mobius 0.3.20 → 0.3.25

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.20",
3
+ "version": "0.3.25",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
package/src/App.tsx CHANGED
@@ -121,13 +121,14 @@ export function App() {
121
121
  setRoute('chat')
122
122
  }
123
123
 
124
- // /model (= /config): swap task+model and start a brand-new session. App owns
125
- // `ready`, so it folds the ConfigFlow result in and remounts Chat on the fresh
126
- // session (resumeSessionId = the eagerly created session).
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')
@@ -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: '更换模型(/model 的别名)' },
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': case '/config': setConfigOpen(true); return true
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 the config flow is open, this ChatScreen-level handler owns Esc so
152
- // cancel is reliable even mid-list-loading (a per-component EscToCancel
153
- // could be unmounted by the loading→loaded transition and drop the keypress).
154
- // configOpen/sessionId are read from handlerRef (see above) because Ink keeps
155
- // the originally-registered callback and would otherwise see a stale closure.
156
- if (handlerRef.current.configOpen) {
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
- let rows = 0
1154
- let first = available.length
1155
- for (let index = available.length - 1; index >= 0; index--) {
1156
- const nextRows = entryRows(available[index], columns)
1157
- if (first < available.length && rows + nextRows > rowBudget) break
1158
- rows += nextRows
1159
- first = index
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 (= /config) flow — pick a model, then create a brand-new session in the
3
- * CURRENT task (Issue) with that model. Launched from inside the chat; Esc at any
4
- * point before the session is created cancels back to the conversation untouched.
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 { cwd, updateIssuePreference, type IssuePreference } from '../config.js'
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
+ }
@@ -10,6 +10,7 @@
10
10
  * Preferences are stored INSIDE the selected Issue (switching issues restores
11
11
  * that issue's saved model/language/skill/memory choices).
12
12
  */
13
+ import { randomBytes } from 'crypto'
13
14
  import React, { useEffect, useState } from 'react'
14
15
  import { Box, Text } from 'ink'
15
16
  import { Select, TextInput, type SelectItem } from './primitives.js'
@@ -116,7 +117,14 @@ export function PrepScreen({ client, onReady, onQuit }: {
116
117
  async function createProject(name: string, description: string) {
117
118
  setStatusMsg('创建项目…')
118
119
  try {
119
- const p = await client.createProject({ name: name || '未命名项目', description, bindPath: thisCwd, defaultUseWorktree: false })
120
+ // bindPath mobius 服务器上的工作目录,不应使用 TUI 客户端的 process.cwd()
121
+ // TUI 可能运行在不同机器/OS 上(尤其是通过 aimux 连接时),
122
+ // 客户端 cwd 对服务器无意义(Windows 路径如 C:\Users\... 在 Linux 上会被误解析为相对路径)。
123
+ // 用项目名+随机后缀生成唯一服务器端子目录,由服务器 resolveBindPath 拼到用户 work_dir 下。
124
+ const slug = (name || '未命名项目').replace(/[^a-zA-Z0-9一-鿿_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 56) || 'project'
125
+ const suffix = randomBytes(3).toString('hex')
126
+ const safeDir = `/${slug}-${suffix}`
127
+ const p = await client.createProject({ name: name || '未命名项目', description, bindPath: safeDir, defaultUseWorktree: false })
120
128
  const list = await client.listProjects(); await saveProjectsCache(list); setProjects(list)
121
129
  await bindCwdToProject(thisCwd, p.id)
122
130
  await enterProject(p, list)