@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,519 @@
1
+ /**
2
+ * Chat screen — a stable, viewport-aware terminal conversation.
3
+ *
4
+ * Ink's <Static> output is intentionally not used here: mixing permanent
5
+ * transcript rows with a dynamic header/composer causes new transcript items to
6
+ * be printed above the header. Keeping the whole screen dynamic gives us the
7
+ * same visual hierarchy as modern coding-agent TUIs: welcome card, conversation,
8
+ * activity, composer, and a persistent context status line.
9
+ */
10
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
11
+ import { Box, Text, useInput, useStdout } from 'ink'
12
+ import { useChat } from '../hooks/useChat.js'
13
+ import { MobiusClient } from '../api.js'
14
+ import { renderMarkdownLines } from '../markdown.js'
15
+ import { viewsForEntry, toolLabel, type EntryView } from '../lib/entry-view.js'
16
+ import type { ReadyState } from './PrepScreen.js'
17
+ import type { AnyEntry } from '../types.js'
18
+ import type { AimuxStatus } from '../aimux.js'
19
+ import { AimuxStatusLine } from './AimuxStatus.js'
20
+
21
+ interface ChatProps {
22
+ client: MobiusClient
23
+ ready: ReadyState
24
+ webUserId: string
25
+ resumeSessionId?: string | null
26
+ onClear: () => void
27
+ onResume: () => void
28
+ onQuit: () => void
29
+ aimuxStatus?: AimuxStatus
30
+ }
31
+
32
+ interface TerminalSize {
33
+ columns: number
34
+ rows: number
35
+ isTty: boolean
36
+ }
37
+
38
+ const VERSION = '0.2.1'
39
+ const WELCOME_ROWS = 12
40
+ const CHROME_ROWS = 11
41
+
42
+ const SLASH_COMMANDS = [
43
+ { cmd: '/clear', desc: '清空当前对话,开启新会话' },
44
+ { cmd: '/resume', desc: '恢复一个历史会话' },
45
+ { cmd: '/help', desc: '显示帮助' },
46
+ { cmd: '/quit', desc: '退出 TUI' },
47
+ ]
48
+
49
+ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, aimuxStatus }: ChatProps) {
50
+ const chat = useChat({ client, ready, resumeSessionId })
51
+ const [showHelp, setShowHelp] = useState(false)
52
+ const terminal = useTerminalSize()
53
+
54
+ const runSlash = useCallback((raw: string) => {
55
+ const [name] = raw.trim().split(/\s+/)
56
+ switch (name) {
57
+ case '/clear': onClear(); return true
58
+ case '/resume': onResume(); return true
59
+ case '/help': setShowHelp(s => !s); return true
60
+ case '/quit': case '/exit': onQuit(); return true
61
+ default: return false
62
+ }
63
+ }, [onClear, onResume, onQuit])
64
+
65
+ const onSubmit = useCallback((text: string) => {
66
+ const t = text.trim()
67
+ if (!t) return
68
+ if (t.startsWith('/')) {
69
+ if (!runSlash(t)) setShowHelp(true)
70
+ return
71
+ }
72
+ setShowHelp(false)
73
+ void chat.send(t)
74
+ }, [chat, runSlash])
75
+
76
+ const transcriptRows = Math.max(5, terminal.rows - CHROME_ROWS)
77
+ const fitted = useMemo(
78
+ () => fitTranscript(chat.entries, transcriptRows, terminal.columns),
79
+ [chat.entries, transcriptRows, terminal.columns],
80
+ )
81
+ const showWelcome = fitted.estimatedRows + WELCOME_ROWS <= transcriptRows
82
+
83
+ return (
84
+ <Box
85
+ flexDirection="column"
86
+ width={terminal.isTty ? terminal.columns : undefined}
87
+ height={terminal.isTty ? Math.max(16, terminal.rows - 1) : undefined}
88
+ paddingX={1}
89
+ overflowY="hidden"
90
+ >
91
+ <Box flexDirection="column" flexGrow={1} overflowY="hidden">
92
+ {showWelcome
93
+ ? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} />
94
+ : <CompactHeader ready={ready} sessionId={chat.sessionId} />}
95
+
96
+ <Box flexDirection="column" marginTop={showWelcome ? 1 : 0}>
97
+ {fitted.hiddenCount > 0
98
+ ? <Text dimColor> … 已隐藏较早的 {fitted.hiddenCount} 条记录;使用 /resume 可重新载入会话</Text>
99
+ : null}
100
+ {fitted.entries.map((entry, index) => (
101
+ <EntryBlock key={entry.__id ?? `entry-${index}`} entry={entry} />
102
+ ))}
103
+ {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
104
+ </Box>
105
+
106
+ {chat.entries.length === 0 && chat.pendingUser === null && !showHelp
107
+ ? <Box marginTop={1}><Text dimColor>输入问题开始协作,或输入 <Text color="cyan">/</Text> 查看命令。</Text></Box>
108
+ : null}
109
+
110
+ {showHelp ? <HelpBlock commands={SLASH_COMMANDS} /> : null}
111
+ </Box>
112
+
113
+ {chat.typing ? <WorkingIndicator /> : null}
114
+ {chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
115
+
116
+ <Composer
117
+ onSubmit={onSubmit}
118
+ onStop={chat.stop}
119
+ onQuit={onQuit}
120
+ typing={chat.typing}
121
+ commands={SLASH_COMMANDS}
122
+ />
123
+ <StatusArea
124
+ ready={ready}
125
+ sessionId={chat.sessionId}
126
+ columns={terminal.columns}
127
+ webUrl={buildWebUrl(client.server, webUserId, ready, chat.sessionId)}
128
+ aimuxStatus={aimuxStatus}
129
+ />
130
+ </Box>
131
+ )
132
+ }
133
+
134
+ function useTerminalSize(): TerminalSize {
135
+ const { stdout } = useStdout()
136
+ const read = useCallback((): TerminalSize => ({
137
+ columns: Math.max(40, stdout.columns ?? 80),
138
+ rows: Math.max(16, stdout.rows ?? 24),
139
+ isTty: Boolean(stdout.isTTY && stdout.columns && stdout.rows),
140
+ }), [stdout])
141
+ const [size, setSize] = useState<TerminalSize>(read)
142
+
143
+ useEffect(() => {
144
+ const onResize = () => setSize(read())
145
+ stdout.on('resize', onResize)
146
+ return () => { stdout.off('resize', onResize) }
147
+ }, [stdout, read])
148
+
149
+ return size
150
+ }
151
+
152
+ function WelcomeCard({ ready, columns, resumed }: { ready: ReadyState; columns: number; resumed: boolean }) {
153
+ const cwd = compactPath(process.cwd())
154
+ const width = Math.max(38, Math.min(68, columns - 4))
155
+ const labelWidth = 11
156
+ return (
157
+ <Box flexDirection="column">
158
+ <Box borderStyle="round" borderColor="gray" borderDimColor width={width} paddingX={1} flexDirection="column">
159
+ <Text>
160
+ <Text dimColor>{'>_ '}</Text>
161
+ <Text bold>Mobius</Text>
162
+ <Text dimColor> (v{VERSION})</Text>
163
+ </Text>
164
+ <Text> </Text>
165
+ <MetaRow label="model:" value={ready.prefs.model ?? 'default'} hint="/help 查看命令" labelWidth={labelWidth} />
166
+ <MetaRow label="project:" value={ready.project.name} labelWidth={labelWidth} />
167
+ <MetaRow label="task:" value={ready.issue.title} labelWidth={labelWidth} />
168
+ <MetaRow label="directory:" value={cwd} labelWidth={labelWidth} />
169
+ </Box>
170
+ <Box marginTop={1} paddingLeft={1}>
171
+ <Text><Text bold>Tip:</Text> {resumed ? '已恢复历史会话;上下文会继续保留。' : '偏好按任务保存;下次进入会自动恢复模型、语言、Skill 与 Memory。'}</Text>
172
+ </Box>
173
+ </Box>
174
+ )
175
+ }
176
+
177
+ function MetaRow({ label, value, hint, labelWidth }: { label: string; value: string; hint?: string; labelWidth: number }) {
178
+ return (
179
+ <Text>
180
+ <Text dimColor>{label.padEnd(labelWidth)}</Text>
181
+ <Text bold>{value}</Text>
182
+ {hint ? <Text dimColor> {hint}</Text> : null}
183
+ </Text>
184
+ )
185
+ }
186
+
187
+ function CompactHeader({ ready, sessionId }: { ready: ReadyState; sessionId: string | null }) {
188
+ return (
189
+ <Box justifyContent="space-between">
190
+ <Text bold><Text dimColor>{'>_ '}</Text>Mobius</Text>
191
+ <Text dimColor>{ready.project.name} › {ready.issue.title}{sessionId ? ` · ${sessionId.slice(0, 8)}` : ''}</Text>
192
+ </Box>
193
+ )
194
+ }
195
+
196
+ function EntryBlock({ entry }: { entry: AnyEntry }) {
197
+ const views = viewsForEntry(entry)
198
+ return (
199
+ <Box flexDirection="column">
200
+ {views.map((view, index) => <ViewLine key={index} view={view} />)}
201
+ </Box>
202
+ )
203
+ }
204
+
205
+ function ViewLine({ view }: { view: EntryView }) {
206
+ switch (view.kind) {
207
+ case 'skip':
208
+ return null
209
+ case 'user':
210
+ return <UserLine text={view.text} />
211
+ case 'assistant': {
212
+ const lines = renderMarkdownLines(view.text)
213
+ return (
214
+ <Box marginTop={1} flexDirection="column">
215
+ {lines.map((line, index) => (
216
+ <Text key={index} wrap={line.code ? 'truncate-end' : 'wrap'}>
217
+ {index === 0 ? '• ' : ' '}{line.text || ' '}
218
+ </Text>
219
+ ))}
220
+ </Box>
221
+ )
222
+ }
223
+ case 'tool_call':
224
+ return (
225
+ <Text>
226
+ <Text color="cyan">• {toolLabel(view.toolName)}</Text>
227
+ {view.summary ? <Text dimColor> {view.summary}</Text> : null}
228
+ </Text>
229
+ )
230
+ case 'tool_result':
231
+ return (
232
+ <Text dimColor color={view.isError ? 'red' : undefined}>
233
+ {' └ '}{view.summary || '(无输出)'}
234
+ </Text>
235
+ )
236
+ case 'reasoning':
237
+ return <Text dimColor color="magenta"> ◇ {view.text}</Text>
238
+ case 'system':
239
+ return <Text dimColor color="yellow"> {view.text}</Text>
240
+ case 'error':
241
+ return <Text color="red">⚠ {view.text}</Text>
242
+ default:
243
+ return null
244
+ }
245
+ }
246
+
247
+ function UserLine({ text }: { text: string }) {
248
+ const lines = text.split('\n')
249
+ if (lines[0] !== undefined) lines[0] = `› ${lines[0]}`
250
+ for (let i = 1; i < lines.length; i++) lines[i] = ` ${lines[i]}`
251
+ return <Box marginTop={1}><Text bold>{lines.join('\n')}</Text></Box>
252
+ }
253
+
254
+ function WorkingIndicator() {
255
+ const startedAt = useRef(Date.now())
256
+ const [animationFrame, setAnimationFrame] = useState(0)
257
+ useEffect(() => {
258
+ const id = setInterval(() => setAnimationFrame(frame => frame + 1), 80)
259
+ return () => clearInterval(id)
260
+ }, [])
261
+ const secs = Math.floor((Date.now() - startedAt.current) / 1000)
262
+ const elapsed = secs >= 60 ? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s` : `${secs}s`
263
+ const label = `• Working (${elapsed} · esc to interrupt)`
264
+ return (
265
+ <Box marginTop={1}>
266
+ <Text>{shimmerText(label, animationFrame)}</Text>
267
+ </Box>
268
+ )
269
+ }
270
+
271
+ // A soft highlight travels through the status text, matching the moving
272
+ // brightness cue used by Codex while keeping the elapsed time readable.
273
+ const SHIMMER_SHADES = ['#ffffff', '#d0d0d0', '#ababab', '#8c8c8c', '#747474', '#666666']
274
+
275
+ export function shimmerText(label: string, frame: number): React.ReactNode[] {
276
+ const chars = Array.from(label)
277
+ const head = chars.length > 0 ? frame % chars.length : 0
278
+ return chars.map((char, index) => {
279
+ const directDistance = Math.abs(index - head)
280
+ const distance = Math.min(directDistance, chars.length - directDistance)
281
+ const shade = SHIMMER_SHADES[Math.min(distance, SHIMMER_SHADES.length - 1)]
282
+ return <Text key={`${index}-${char}`} color={shade}>{char}</Text>
283
+ })
284
+ }
285
+
286
+ function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] }) {
287
+ return (
288
+ <Box flexDirection="column" borderStyle="round" borderColor="gray" borderDimColor paddingX={1} marginTop={1}>
289
+ {commands.map(command => (
290
+ <Text key={command.cmd}>
291
+ <Text color="cyan" bold>{command.cmd.padEnd(10)}</Text>
292
+ <Text>{command.desc}</Text>
293
+ </Text>
294
+ ))}
295
+ </Box>
296
+ )
297
+ }
298
+
299
+ interface ComposerProps {
300
+ onSubmit: (text: string) => void
301
+ onStop: () => void
302
+ onQuit: () => void
303
+ typing: boolean
304
+ commands: { cmd: string; desc: string }[]
305
+ }
306
+
307
+ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps) {
308
+ const [value, setValue] = useState('')
309
+ const [cursor, setCursor] = useState(0)
310
+ const [popupIdx, setPopupIdx] = useState(0)
311
+ const [popupDismissed, setPopupDismissed] = useState(false)
312
+ const historyRef = useRef<string[]>([])
313
+ const [histIdx, setHistIdx] = useState<number | null>(null)
314
+
315
+ const filtered = useMemo(() => {
316
+ const match = /^(\w*)$/.exec(value.slice(1))
317
+ if (!value.startsWith('/') || match === null) return []
318
+ const prefix = value.slice(1)
319
+ return commands.filter(command => command.cmd.slice(1).startsWith(prefix))
320
+ }, [value, commands])
321
+
322
+ useEffect(() => { setPopupIdx(0); setPopupDismissed(false) }, [value])
323
+ const popupOpen = !popupDismissed && value.startsWith('/') && filtered.length > 0 && value.trim() !== filtered[popupIdx]?.cmd
324
+
325
+ function edit(next: string, nextCursor: number) {
326
+ setValue(next)
327
+ setCursor(nextCursor)
328
+ }
329
+
330
+ useInput((input, key) => {
331
+ if (typing && key.escape) { void onStop(); return }
332
+
333
+ if (popupOpen) {
334
+ if (key.upArrow) { setPopupIdx(i => (i <= 0 ? filtered.length - 1 : i - 1)); return }
335
+ if (key.downArrow) { setPopupIdx(i => (i + 1) % filtered.length); return }
336
+ if (key.return || key.tab) {
337
+ const pick = filtered[popupIdx >= 0 ? popupIdx : 0]
338
+ if (pick) { edit(`${pick.cmd} `, pick.cmd.length + 1); setPopupDismissed(true); return }
339
+ }
340
+ if (key.escape) { setPopupDismissed(true); return }
341
+ }
342
+
343
+ if (key.return) {
344
+ if (value.trim()) {
345
+ historyRef.current.push(value)
346
+ onSubmit(value)
347
+ edit('', 0)
348
+ setHistIdx(null)
349
+ }
350
+ return
351
+ }
352
+ if (key.ctrl && input === 'c') { typing ? void onStop() : onQuit(); return }
353
+ // Ink reports the terminal Backspace key (\x7f) as `key.delete`; handle both
354
+ // as a backward delete so Backspace works at the end of the input.
355
+ if (key.backspace || key.delete) {
356
+ if (cursor > 0) edit(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1)
357
+ return
358
+ }
359
+ if (key.leftArrow) { setCursor(current => Math.max(0, current - 1)); return }
360
+ if (key.rightArrow) { setCursor(current => Math.min(value.length, current + 1)); return }
361
+
362
+ const onFirstLine = value.slice(0, cursor).indexOf('\n') === -1
363
+ if (key.upArrow && onFirstLine) {
364
+ const history = historyRef.current
365
+ if (history.length) {
366
+ const next = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1)
367
+ setHistIdx(next)
368
+ edit(history[next], history[next].length)
369
+ }
370
+ return
371
+ }
372
+ if (key.downArrow && histIdx !== null) {
373
+ const history = historyRef.current
374
+ const next = histIdx + 1
375
+ if (next >= history.length) { setHistIdx(null); edit('', 0) }
376
+ else { setHistIdx(next); edit(history[next], history[next].length) }
377
+ return
378
+ }
379
+ if (key.ctrl && input === 'a') { setCursor(0); return }
380
+ if (key.ctrl && input === 'e') { setCursor(value.length); return }
381
+ if (key.ctrl && input === 'u') { edit('', 0); return }
382
+ if (key.ctrl && input === 'k') { edit(value.slice(0, cursor), cursor); return }
383
+ if (key.ctrl && input === 'j') { edit(value.slice(0, cursor) + '\n' + value.slice(cursor), cursor + 1); return }
384
+ if (key.ctrl || key.meta || key.escape || !input) return
385
+ edit(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length)
386
+ })
387
+
388
+ const lines = value.split('\n')
389
+ const lineIdx = value.slice(0, cursor).match(/\n/g)?.length ?? 0
390
+ const col = cursor - (value.slice(0, cursor).lastIndexOf('\n') + 1)
391
+ const currentLine = lines[lineIdx] ?? ''
392
+ const beforeCursor = currentLine.slice(0, col)
393
+ const atCursor = currentLine.slice(col, col + 1)
394
+ const afterCursor = currentLine.slice(col + 1)
395
+
396
+ return (
397
+ <Box flexDirection="column" marginTop={1}>
398
+ {popupOpen ? (
399
+ <Box flexDirection="column" marginBottom={1} paddingLeft={2}>
400
+ {filtered.map((command, index) => (
401
+ <Text
402
+ key={command.cmd}
403
+ backgroundColor={index === popupIdx ? 'white' : undefined}
404
+ color={index === popupIdx ? 'black' : undefined}
405
+ >
406
+ {index === popupIdx ? '› ' : ' '}{command.cmd.padEnd(10)}<Text dimColor> {command.desc}</Text>
407
+ </Text>
408
+ ))}
409
+ </Box>
410
+ ) : null}
411
+ <Box>
412
+ <Text bold>{'› '}</Text>
413
+ <Text>
414
+ {lines.map((line, index) => {
415
+ if (index < lineIdx) return <Text key={index}>{line}{'\n'}</Text>
416
+ if (index === lineIdx) {
417
+ return (
418
+ <Text key={index}>
419
+ {beforeCursor}<Text backgroundColor="white" color="black">{atCursor || ' '}</Text>{afterCursor}
420
+ {index < lines.length - 1 ? '\n' : ''}
421
+ </Text>
422
+ )
423
+ }
424
+ return <Text key={index}>{'\n'}{line}</Text>
425
+ })}
426
+ {value === '' ? <Text dimColor> 输入问题或 / 命令</Text> : null}
427
+ </Text>
428
+ </Box>
429
+ </Box>
430
+ )
431
+ }
432
+
433
+ function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus }: {
434
+ ready: ReadyState
435
+ sessionId: string | null
436
+ columns: number
437
+ webUrl: string
438
+ aimuxStatus?: AimuxStatus
439
+ }) {
440
+ const model = ready.prefs.model ?? 'default'
441
+ const language = ready.prefs.language === 'en' ? 'English' : '中文'
442
+ const cwd = compactPath(process.cwd())
443
+ const leftRaw = columns >= 100
444
+ ? `${model} · ${language} · ${cwd}`
445
+ : `${model} · ${ready.project.name}`
446
+ const rightRaw = columns >= 72
447
+ ? `${ready.project.name} › ${ready.issue.title}${sessionId ? ` · ${sessionId.slice(0, 8)}` : ''}`
448
+ : ''
449
+ const left = truncateDisplay(leftRaw, rightRaw ? Math.floor(columns * 0.45) : columns - 2)
450
+ const right = rightRaw ? truncateDisplay(rightRaw, columns - left.length - 5) : ''
451
+ return (
452
+ <Box flexDirection="column" marginTop={1}>
453
+ <Box justifyContent="space-between">
454
+ <Text dimColor>{left}</Text>
455
+ {right ? <Text dimColor>{right}</Text> : null}
456
+ </Box>
457
+ <Text>
458
+ <Text dimColor>web · </Text>
459
+ <Text color="cyan" underline>{clickableUrl(webUrl)}</Text>
460
+ </Text>
461
+ {aimuxStatus ? <AimuxStatusLine status={aimuxStatus} compact /> : null}
462
+ </Box>
463
+ )
464
+ }
465
+
466
+ function compactPath(path: string): string {
467
+ const home = process.env.HOME
468
+ if (!home) return path
469
+ if (path === home) return '~'
470
+ return path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path
471
+ }
472
+
473
+ function truncateDisplay(value: string, maxLength: number): string {
474
+ if (value.length <= maxLength) return value
475
+ return maxLength <= 1 ? '…' : `${value.slice(0, maxLength - 1)}…`
476
+ }
477
+
478
+ function buildWebUrl(server: string, webUserId: string, ready: ReadyState, sessionId: string | null): string {
479
+ const root = server.replace(/\/+$/, '')
480
+ const user = webUserId || ready.project.created_by || ready.issue.created_by || 'current'
481
+ const base = `${root}/u/${encodeURIComponent(user)}/p/${encodeURIComponent(ready.project.id)}/i/${encodeURIComponent(ready.issue.id)}`
482
+ return sessionId ? `${base}?session=${encodeURIComponent(sessionId)}` : base
483
+ }
484
+
485
+ /** OSC 8 hyperlinks remain readable as plain URLs in terminals without support. */
486
+ function clickableUrl(url: string): string {
487
+ if (process.env.MOBIUS_TUI_DISABLE_LINKS === '1') return url
488
+ return `\u001B]8;;${url}\u0007${url}\u001B]8;;\u0007`
489
+ }
490
+
491
+ function wrappedRows(text: string, columns: number): number {
492
+ const width = Math.max(20, columns - 6)
493
+ return text.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / width)), 0)
494
+ }
495
+
496
+ function entryRows(entry: AnyEntry, columns: number): number {
497
+ return viewsForEntry(entry).reduce((sum, view) => {
498
+ if (view.kind === 'skip') return sum
499
+ if (view.kind === 'tool_call') return sum + wrappedRows(`${toolLabel(view.toolName)} ${view.summary}`, columns)
500
+ if (view.kind === 'tool_result') return sum + wrappedRows(view.summary, columns)
501
+ return sum + wrappedRows(view.text, columns) + (view.kind === 'user' || view.kind === 'assistant' ? 1 : 0)
502
+ }, 0)
503
+ }
504
+
505
+ function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number): {
506
+ entries: AnyEntry[]
507
+ hiddenCount: number
508
+ estimatedRows: number
509
+ } {
510
+ let rows = 0
511
+ let first = entries.length
512
+ for (let index = entries.length - 1; index >= 0; index--) {
513
+ const nextRows = entryRows(entries[index], columns)
514
+ if (first < entries.length && rows + nextRows > rowBudget) break
515
+ rows += nextRows
516
+ first = index
517
+ }
518
+ return { entries: entries.slice(first), hiddenCount: first, estimatedRows: rows }
519
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Login screen.
3
+ *
4
+ * Mirrors the desktop electron login form (desktop/src/login.ts): collect
5
+ * server URL + username + optional password, POST /api/auth/login, persist the
6
+ * result to ~/.mobius/login.json. Password is only required when the server's
7
+ * /api/auth/config reports password_required=true (Mobius defaults to
8
+ * passwordless).
9
+ */
10
+ import React, { useEffect, useState } from 'react'
11
+ import { Box, Text } from 'ink'
12
+ import { TextInput } from './primitives.js'
13
+ import { getAuthConfig, login, ApiError } from '../api.js'
14
+ import { saveLogin, type LoginRecord } from '../config.js'
15
+
16
+ const DEFAULT_SERVER = ''
17
+
18
+ export function LoginScreen({ onSuccess, onError }: {
19
+ onSuccess: (rec: LoginRecord) => void
20
+ onError?: (msg: string) => void
21
+ }) {
22
+ const [server, setServer] = useState(DEFAULT_SERVER)
23
+ const [username, setUsername] = useState('')
24
+ const [password, setPassword] = useState('')
25
+ const [pwdRequired, setPwdRequired] = useState<boolean | null>(null)
26
+ const [focus, setFocus] = useState(0) // 0 server, 1 user, 2 password
27
+ const [busy, setBusy] = useState(false)
28
+ const [error, setError] = useState<string | null>(null)
29
+
30
+ useEffect(() => {
31
+ if (!server.trim()) { setPwdRequired(null); return }
32
+ getAuthConfig(server).then(c => setPwdRequired(!!c.password_required)).catch(() => setPwdRequired(false))
33
+ }, [server])
34
+
35
+ async function submit() {
36
+ if (!server.trim()) { setError('请输入服务地址'); setFocus(0); return }
37
+ if (!username.trim()) { setError('请输入用户名'); setFocus(1); return }
38
+ setBusy(true); setError(null)
39
+ try {
40
+ const r = await login(server.trim(), username.trim(), password || undefined)
41
+ const rec: LoginRecord = { server: server.trim().replace(/\/+$/, ''), username: username.trim(), password: password || undefined, token: r.token, user: r.user }
42
+ await saveLogin(rec)
43
+ onSuccess(rec)
44
+ } catch (e: any) {
45
+ const msg = e instanceof ApiError ? e.message : `登录失败: ${e?.message ?? String(e)}`
46
+ setError(msg)
47
+ onError?.(msg)
48
+ } finally {
49
+ setBusy(false)
50
+ }
51
+ }
52
+
53
+ const fields = [
54
+ { label: '服务地址', value: server, set: setServer, placeholder: 'https://your-mobius-server.example.com', mask: false },
55
+ { label: '用户名', value: username, set: setUsername, placeholder: 'your-username', mask: false },
56
+ { label: '密码', value: password, set: setPassword, placeholder: pwdRequired === false ? '(此服务器免密,留空即可)' : '••••', mask: true },
57
+ ]
58
+
59
+ return (
60
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
61
+ <Text bold color="cyan">╭─ Mobius 登录 ─╮</Text>
62
+ <Text color="gray">连接到 Mobius 服务并保存登录态到 ~/.mobius/login.json</Text>
63
+ <Box marginTop={1} flexDirection="column">
64
+ {fields.map((f, i) => (
65
+ <Box key={i} flexDirection="column" marginBottom={1}>
66
+ <Text color={focus === i ? 'cyan' : 'gray'}>{f.label}{focus === i ? ' ←' : ''}</Text>
67
+ <TextInput
68
+ value={f.value}
69
+ onChange={f.set}
70
+ focused={focus === i}
71
+ mask={f.mask}
72
+ placeholder={f.placeholder}
73
+ onSubmit={() => {
74
+ if (i < fields.length - 1) setFocus(i + 1)
75
+ else submit()
76
+ }}
77
+ onTab={() => setFocus((i + 1) % fields.length)}
78
+ />
79
+ </Box>
80
+ ))}
81
+ </Box>
82
+ {error ? <Text color="red">⚠ {error}</Text> : null}
83
+ {busy ? <Text color="yellow">登录中…</Text> : (
84
+ <Text color="gray">回车提交 · Tab 切换字段 · {pwdRequired === false ? '免密模式' : (pwdRequired === true ? '需要密码' : '')}</Text>
85
+ )}
86
+ </Box>
87
+ )
88
+ }