@mobius-os/mobius 0.3.34 → 0.3.42
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/scripts/build-python-bundles.sh +2 -2
- package/src/App.tsx +18 -2
- package/src/aimux.ts +69 -11
- package/src/components/Chat.tsx +65 -11
- package/src/components/ConfigFlow.tsx +30 -6
- package/src/components/Login.tsx +5 -3
- package/src/components/PrepScreen.tsx +98 -25
- package/src/components/primitives.tsx +23 -4
- package/src/lib/cursor-keys.ts +70 -0
- package/src/lib/delete-keys.ts +4 -4
- package/src/lib/entry-view.ts +40 -0
- package/src/markdown.ts +34 -10
- package/tests/aimux.test.tsx +41 -10
- package/tests/flow.test.tsx +46 -4
- package/tests/scroll.test.tsx +3 -3
- package/tests/selection.test.tsx +1 -1
- package/tests/ui.test.tsx +271 -9
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import React, { useEffect, useState } from 'react'
|
|
13
13
|
import { Box, Text } from 'ink'
|
|
14
14
|
import { MobiusClient, getMe, login, ApiError } from './api.js'
|
|
15
|
-
import { loadLogin, saveLogin, type LoginRecord } from './config.js'
|
|
15
|
+
import { clearLogin, loadLogin, saveLogin, type LoginRecord } from './config.js'
|
|
16
16
|
import { LoginScreen } from './components/Login.js'
|
|
17
17
|
import { PrepScreen, type ReadyState } from './components/PrepScreen.js'
|
|
18
18
|
import { ChatScreen } from './components/Chat.js'
|
|
@@ -150,6 +150,21 @@ export function App() {
|
|
|
150
150
|
void stopAimuxConnection().finally(() => process.exit(0))
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
async function onLogout() {
|
|
154
|
+
if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] logout')
|
|
155
|
+
try {
|
|
156
|
+
await clearLogin()
|
|
157
|
+
await stopAimuxConnection()
|
|
158
|
+
} finally {
|
|
159
|
+
setClient(null)
|
|
160
|
+
setUserId(null)
|
|
161
|
+
setReady(null)
|
|
162
|
+
setResumeSessionId(null)
|
|
163
|
+
setAimuxStatus({ state: 'stopped', phase: 'idle', detail: '登录后自动连接' })
|
|
164
|
+
setRoute('login')
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
153
168
|
// ── render ─────────────────────────────────────────────────────────────────
|
|
154
169
|
// The chat screen already pins itself to the terminal height, so render it
|
|
155
170
|
// bare — a <Screen> wrapper would clip its transcript in short terminals and
|
|
@@ -168,6 +183,7 @@ export function App() {
|
|
|
168
183
|
onClear={onClear}
|
|
169
184
|
onResume={onResume}
|
|
170
185
|
onQuit={onQuit}
|
|
186
|
+
onLogout={() => { void onLogout() }}
|
|
171
187
|
onReconfigure={onReconfigure}
|
|
172
188
|
onConfigCancel={onConfigCancel}
|
|
173
189
|
aimuxStatus={aimuxStatus}
|
|
@@ -178,7 +194,7 @@ export function App() {
|
|
|
178
194
|
if (route === 'boot') {
|
|
179
195
|
node = <Box paddingX={2} paddingY={1}><Text color="cyan">{bootMsg}</Text></Box>
|
|
180
196
|
} else if (route === 'login' || !client) {
|
|
181
|
-
node = <LoginScreen onSuccess={onLoginSuccess} />
|
|
197
|
+
node = <LoginScreen onSuccess={onLoginSuccess} initialServer={prefill.server} initialUsername={prefill.username} />
|
|
182
198
|
} else if (route === 'resume' && ready) {
|
|
183
199
|
node = <Box flexDirection="column"><AimuxStatusLine status={aimuxStatus} compact /><ResumePicker client={client} project={ready.project} onPick={onResumed} onBack={() => setRoute('chat')} /></Box>
|
|
184
200
|
} else {
|
package/src/aimux.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* currently authenticated Mobius server. Nothing is started before login.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
9
10
|
import { promises as fs, existsSync, createWriteStream } from 'node:fs'
|
|
10
11
|
import os from 'node:os'
|
|
11
12
|
import path from 'node:path'
|
|
@@ -88,8 +89,8 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
|
|
|
88
89
|
// 解压到 ~/.mobius/python-bundle/ 后用 `<python> -m aimux` 运行,彻底绕开宿主机
|
|
89
90
|
// 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
|
|
90
91
|
// 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
|
|
91
|
-
const BUNDLE_VER = '
|
|
92
|
-
const BUNDLE_AIMUX_VERSION = '0.1.
|
|
92
|
+
const BUNDLE_VER = '3'
|
|
93
|
+
const BUNDLE_AIMUX_VERSION = '0.1.23'
|
|
93
94
|
const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
|
|
94
95
|
const bundlePython = () => WIN
|
|
95
96
|
? path.join(bundleDir(), 'python', 'python.exe')
|
|
@@ -300,32 +301,76 @@ export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Pr
|
|
|
300
301
|
return { ok: false, error: `${venvError};内置运行时也失败: ${bundle.error}` }
|
|
301
302
|
}
|
|
302
303
|
|
|
303
|
-
export function tuiAimuxIdentifier(): string {
|
|
304
|
-
const host =
|
|
305
|
-
|
|
304
|
+
export function tuiAimuxIdentifier(hostname = os.hostname(), cwd = process.cwd()): string {
|
|
305
|
+
const host = hostname.toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32)
|
|
306
|
+
// One machine may run several Mobius TUIs for different projects. A
|
|
307
|
+
// hostname-only identifier makes every reverse client register with the
|
|
308
|
+
// same name and --replace continuously evicts its siblings ("client
|
|
309
|
+
// replaced"). The normalized cwd hash is stable across restarts/resume but
|
|
310
|
+
// unique for the common multi-project case.
|
|
311
|
+
const workspace = createHash('sha256').update(path.resolve(cwd)).digest('hex').slice(0, 10)
|
|
312
|
+
return `tui-${host || 'pc'}-${workspace}`
|
|
306
313
|
}
|
|
307
314
|
|
|
308
315
|
/**
|
|
309
|
-
* Build the reverse-connect command in one place.
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
316
|
+
* Build the reverse-connect command in one place. On Windows the bridge shells
|
|
317
|
+
* must run headless or every remote command flashes a console and steals
|
|
318
|
+
* keyboard focus from the TUI — but *which* flag asks for that depends on the
|
|
319
|
+
* installed aimux: `--silent-shell` landed in 0.1.18, the no-console
|
|
320
|
+
* `--slient-v2`/`--silent-v2` only in 0.1.22+. PyPI and cached bundles in the
|
|
321
|
+
* wild are often older, and hard-coding any one spelling makes Click reject the
|
|
322
|
+
* whole command ("No such option: --slient-v2") and the supervisor crash-loop.
|
|
323
|
+
* So the caller probes `reverse connect --help` once (see probeReverseConnectHelp
|
|
324
|
+
* + pickSilentFlag) and passes the flag aimux actually advertises; when nothing
|
|
325
|
+
* is supported we send nothing rather than crash.
|
|
313
326
|
*/
|
|
314
327
|
export function reverseConnectArgs(
|
|
315
328
|
server: string,
|
|
316
329
|
identifier: string,
|
|
317
330
|
token: string,
|
|
318
331
|
platform: NodeJS.Platform = process.platform,
|
|
332
|
+
silentFlag: string | null = null,
|
|
319
333
|
): string[] {
|
|
320
334
|
return [
|
|
321
335
|
'reverse', 'connect', `${server.replace(/\/$/, '')}/aimux_bridge`,
|
|
322
336
|
'--identifier', identifier,
|
|
323
337
|
'--token', token,
|
|
324
338
|
'--replace',
|
|
325
|
-
...(platform === 'win32' ? [
|
|
339
|
+
...(platform === 'win32' && silentFlag ? [silentFlag] : []),
|
|
326
340
|
]
|
|
327
341
|
}
|
|
328
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Capture `aimux reverse connect --help` so we can see which console-hiding
|
|
345
|
+
* flags this particular build advertises. Returns '' on any failure (the
|
|
346
|
+
* caller then sends no silent flag and stays alive instead of crash-looping).
|
|
347
|
+
*/
|
|
348
|
+
export async function probeReverseConnectHelp(launcher: AimuxLauncher): Promise<string> {
|
|
349
|
+
const base = launcher.kind === 'exe'
|
|
350
|
+
? { cmd: launcher.path, args: ['reverse', 'connect', '--help'] }
|
|
351
|
+
: { cmd: launcher.python, args: ['-m', 'aimux', 'reverse', 'connect', '--help'] }
|
|
352
|
+
try {
|
|
353
|
+
const r = await run(base.cmd, base.args)
|
|
354
|
+
return `${r.stdout}\n${r.stderr}`
|
|
355
|
+
} catch {
|
|
356
|
+
return ''
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Pick the strongest console-hiding flag aimux advertised for Windows. Prefers
|
|
362
|
+
* the correctly-spelled --silent-v2 (future-proof if the historical --slient-v2
|
|
363
|
+
* typo alias is ever dropped), then the --slient-v2 alias, then --silent-shell.
|
|
364
|
+
* Returns null off-Windows or when the installed aimux supports none.
|
|
365
|
+
*/
|
|
366
|
+
export function pickSilentFlag(helpText: string, platform: NodeJS.Platform = process.platform): string | null {
|
|
367
|
+
if (platform !== 'win32') return null
|
|
368
|
+
if (/--silent-v2\b/.test(helpText)) return '--silent-v2'
|
|
369
|
+
if (/--slient-v2\b/.test(helpText)) return '--slient-v2'
|
|
370
|
+
if (/--silent-shell\b/.test(helpText)) return '--silent-shell'
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
|
|
329
374
|
export async function probeAimuxBridgeConnection(
|
|
330
375
|
server: string,
|
|
331
376
|
token: string,
|
|
@@ -504,6 +549,10 @@ export class AimuxSupervisor {
|
|
|
504
549
|
|
|
505
550
|
let supervisor: AimuxSupervisor | null = null
|
|
506
551
|
let installing: Promise<void> | null = null
|
|
552
|
+
// Resolved Windows console-hiding flag for the installed aimux (undefined =
|
|
553
|
+
// not probed yet this process). Cached so reconnects reuse it without re-running
|
|
554
|
+
// `aimux reverse connect --help`. See reverseConnectArgs for why this is probed.
|
|
555
|
+
let cachedSilentFlag: string | null | undefined = undefined
|
|
507
556
|
|
|
508
557
|
export async function startAimuxConnection(opts: { server: string; token: string; onStatus?: (s: AimuxStatus) => void }): Promise<void> {
|
|
509
558
|
const onStatus = opts.onStatus ?? (() => {})
|
|
@@ -525,9 +574,18 @@ export async function startAimuxConnection(opts: { server: string; token: string
|
|
|
525
574
|
if (!ready.ok || !ready.launcher) { logInstall(`startAimuxConnection giving up: ${ready.error}\n`); onStatus({ state: 'failed', phase: 'idle', detail: `${ready.error} · 日志: ${aimuxLogPath()}` }); return }
|
|
526
575
|
const identifier = tuiAimuxIdentifier()
|
|
527
576
|
const launcher = ready.launcher
|
|
577
|
+
// Windows only: ask the installed aimux which console-hiding flag it accepts
|
|
578
|
+
// before spawning, so a version mismatch (older PyPI/bundle aimux without
|
|
579
|
+
// --slient-v2) can't crash-loop the supervisor with "No such option".
|
|
580
|
+
if (WIN && cachedSilentFlag === undefined) {
|
|
581
|
+
const help = await probeReverseConnectHelp(launcher)
|
|
582
|
+
cachedSilentFlag = pickSilentFlag(help)
|
|
583
|
+
logInstall(`reverse-connect silent flag probe → ${cachedSilentFlag ?? '(none supported; sending no flag)'}\n`)
|
|
584
|
+
}
|
|
585
|
+
const silentFlag = cachedSilentFlag
|
|
528
586
|
supervisor = new AimuxSupervisor({
|
|
529
587
|
server: opts.server, token: opts.token, identifier, onStatus,
|
|
530
|
-
spawnProcess: () => spawnLauncher(launcher, reverseConnectArgs(opts.server, identifier, opts.token)),
|
|
588
|
+
spawnProcess: () => spawnLauncher(launcher, reverseConnectArgs(opts.server, identifier, opts.token, process.platform, silentFlag)),
|
|
531
589
|
})
|
|
532
590
|
supervisor.start()
|
|
533
591
|
})().finally(() => { installing = null })
|
package/src/components/Chat.tsx
CHANGED
|
@@ -27,7 +27,8 @@ import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
|
|
|
27
27
|
import type { AimuxStatus } from '../aimux.js'
|
|
28
28
|
import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
|
|
29
29
|
import { isEscapeKeypress, isMouseInput, useMouseEvents, useStableInput } from './primitives.js'
|
|
30
|
-
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary } from '../lib/delete-keys.js'
|
|
30
|
+
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary, previousWordBoundary, nextWordBoundary } from '../lib/delete-keys.js'
|
|
31
|
+
import { useCursorKeyCapture } from '../lib/cursor-keys.js'
|
|
31
32
|
|
|
32
33
|
interface ChatProps {
|
|
33
34
|
client: MobiusClient
|
|
@@ -37,6 +38,7 @@ interface ChatProps {
|
|
|
37
38
|
onClear: () => void
|
|
38
39
|
onResume: () => void
|
|
39
40
|
onQuit: () => void
|
|
41
|
+
onLogout: () => void
|
|
40
42
|
onReconfigure: (result: ConfigResult) => void
|
|
41
43
|
onConfigCancel: (sessionId: string | null) => void
|
|
42
44
|
aimuxStatus?: AimuxStatus
|
|
@@ -54,14 +56,16 @@ const STATUS_ROWS = 3
|
|
|
54
56
|
|
|
55
57
|
const SLASH_COMMANDS = [
|
|
56
58
|
{ cmd: '/clear', desc: '清空当前对话,开启新会话' },
|
|
59
|
+
{ cmd: '/compact', desc: '压缩当前会话上下文' },
|
|
57
60
|
{ cmd: '/resume', desc: '恢复一个历史会话' },
|
|
58
61
|
{ cmd: '/model', desc: '更换模型并开启新会话(保留当前任务)' },
|
|
59
62
|
{ cmd: '/config', desc: '重新选择项目、任务和模型' },
|
|
63
|
+
{ cmd: '/logout', desc: '断开当前连接并返回登录界面' },
|
|
60
64
|
{ cmd: '/help', desc: '显示帮助' },
|
|
61
65
|
{ cmd: '/quit', desc: '退出 TUI' },
|
|
62
66
|
]
|
|
63
67
|
|
|
64
|
-
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
|
|
68
|
+
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onLogout, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
|
|
65
69
|
const chat = useChat({ client, ready, resumeSessionId })
|
|
66
70
|
const [showHelp, setShowHelp] = useState(false)
|
|
67
71
|
// null means "follow the tail". A concrete anchor identifies the exact row
|
|
@@ -71,6 +75,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
71
75
|
const [modelLabel, setModelLabel] = useState<string | null>(null)
|
|
72
76
|
const [configOpen, setConfigOpen] = useState(false)
|
|
73
77
|
const [reconfigOpen, setReconfigOpen] = useState(false)
|
|
78
|
+
// 本地命令错误 (如无会话时 /compact): 与 chat.error 分开, 下一次提交时清除。
|
|
79
|
+
const [slashError, setSlashError] = useState<string | null>(null)
|
|
74
80
|
// Ink may deliver one final event to Composer while an async config picker is
|
|
75
81
|
// replacing it. The shared ref lets that stale listener report "not handled"
|
|
76
82
|
// so App can replay the key after the new Select mounts.
|
|
@@ -87,18 +93,33 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
87
93
|
const [name] = raw.trim().split(/\s+/)
|
|
88
94
|
switch (name) {
|
|
89
95
|
case '/clear': onClear(); return true
|
|
96
|
+
case '/compact': {
|
|
97
|
+
// 对齐 web sendCompactCommand: 把字面 '/compact' 作为消息发给后端, 由
|
|
98
|
+
// agent (claude-code 原生 slash command / codex) 自行执行上下文压缩。
|
|
99
|
+
// 尚无会话时没有可压缩的上文, 提示而不是新建空会话去发。
|
|
100
|
+
if (!chat.sessionId) {
|
|
101
|
+
setSlashError('当前没有可发送指令的会话')
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
setShowHelp(false)
|
|
105
|
+
setRowAnchor(null)
|
|
106
|
+
void chat.send('/compact')
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
90
109
|
case '/resume': onResume(); return true
|
|
91
110
|
case '/help': setShowHelp(s => !s); return true
|
|
92
111
|
case '/model': setConfigOpen(true); return true
|
|
93
112
|
case '/config': setReconfigOpen(true); return true
|
|
113
|
+
case '/logout': onLogout(); return true
|
|
94
114
|
case '/quit': case '/exit': onQuit(); return true
|
|
95
115
|
default: return false
|
|
96
116
|
}
|
|
97
|
-
}, [onClear, onResume, onQuit])
|
|
117
|
+
}, [chat, onClear, onResume, onQuit, onLogout])
|
|
98
118
|
|
|
99
119
|
const onSubmit = useCallback((text: string) => {
|
|
100
120
|
const t = text.trim()
|
|
101
121
|
if (!t) return
|
|
122
|
+
setSlashError(null)
|
|
102
123
|
if (t.startsWith('/')) {
|
|
103
124
|
if (!runSlash(t)) setShowHelp(true)
|
|
104
125
|
return
|
|
@@ -156,7 +177,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
156
177
|
(entry) => rowsForEntry(entry),
|
|
157
178
|
), [transcriptEntries, rowsForEntry])
|
|
158
179
|
const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
|
|
159
|
-
const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
|
|
180
|
+
const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0) + (slashError ? 1 : 0)
|
|
160
181
|
const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
|
|
161
182
|
// Conversation chrome is exactly two rows: compact header + navigation.
|
|
162
183
|
const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 2)
|
|
@@ -201,17 +222,19 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
201
222
|
// the keypress). configOpen/reconfigOpen/sessionId are read from handlerRef
|
|
202
223
|
// (see above) because Ink keeps the originally-registered callback and would
|
|
203
224
|
// otherwise see a stale closure.
|
|
204
|
-
if (handlerRef.current.configOpen
|
|
225
|
+
if (handlerRef.current.configOpen) {
|
|
205
226
|
if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
|
|
206
227
|
return
|
|
207
228
|
}
|
|
229
|
+
if (handlerRef.current.reconfigOpen) return // ReconfigFlow owns hierarchical Esc navigation.
|
|
208
230
|
if (key.pageUp) scrollRows(-pageRows)
|
|
209
231
|
else if (key.pageDown) scrollRows(pageRows)
|
|
210
232
|
}, { interactive: false })
|
|
211
233
|
|
|
212
|
-
const
|
|
213
|
-
?
|
|
214
|
-
:
|
|
234
|
+
const navigationPosition = viewport.hasOlder
|
|
235
|
+
? (viewport.hasNewer ? '↑ 较早内容 · ' : '↑ 还有较早内容')
|
|
236
|
+
: (viewport.hasNewer ? '已到最早 · ' : '全部内容')
|
|
237
|
+
const navigationDetail = ` · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
|
|
215
238
|
|
|
216
239
|
// Mouse: wheel pages through history in small fixed steps, and a left-button
|
|
217
240
|
// drag selects transcript text (tmux-style: the app owns the mouse, draws its
|
|
@@ -310,6 +333,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
310
333
|
<ReconfigFlow
|
|
311
334
|
client={client}
|
|
312
335
|
onDone={(result) => onReconfigure(result)}
|
|
336
|
+
onCancel={() => onConfigCancel(chat.sessionId)}
|
|
313
337
|
/>
|
|
314
338
|
</Box>
|
|
315
339
|
)
|
|
@@ -329,7 +353,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
329
353
|
: <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
|
|
330
354
|
|
|
331
355
|
{!showWelcome
|
|
332
|
-
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {
|
|
356
|
+
? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {navigationPosition}{viewport.hasNewer ? <Text color="yellowBright">↓ 有新内容</Text> : null}{navigationDetail}</Text></Box>
|
|
333
357
|
: null}
|
|
334
358
|
|
|
335
359
|
<Box
|
|
@@ -359,6 +383,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
359
383
|
<Box flexDirection="column" flexShrink={0}>
|
|
360
384
|
{chat.typing ? <WorkingIndicator firstQuery={firstQueryInFlight} /> : null}
|
|
361
385
|
{chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
|
|
386
|
+
{slashError ? <Text color="red">⚠ {slashError}</Text> : null}
|
|
362
387
|
|
|
363
388
|
<Composer
|
|
364
389
|
onSubmit={onSubmit}
|
|
@@ -568,6 +593,11 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
568
593
|
const [popupDismissed, setPopupDismissed] = useState(false)
|
|
569
594
|
const historyRef = useRef<string[]>([])
|
|
570
595
|
const [histIdx, setHistIdx] = useState<number | null>(null)
|
|
596
|
+
// Idle Ctrl+C quits, but a single accidental press must not kill the session:
|
|
597
|
+
// the first press arms a 2s confirmation window, the second press exits.
|
|
598
|
+
const [confirmQuit, setConfirmQuit] = useState(false)
|
|
599
|
+
const confirmQuitRef = useRef(false)
|
|
600
|
+
const confirmQuitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
571
601
|
const valueRef = useRef(value)
|
|
572
602
|
const cursorRef = useRef(cursor)
|
|
573
603
|
const pasteRef = useRef<ComposerPasteState>({
|
|
@@ -581,6 +611,8 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
581
611
|
})
|
|
582
612
|
const { stdout } = useStdout()
|
|
583
613
|
|
|
614
|
+
useEffect(() => () => { if (confirmQuitTimer.current) clearTimeout(confirmQuitTimer.current) }, [])
|
|
615
|
+
|
|
584
616
|
// Physical Backspace/Delete keys are owned by useDeleteKeyCapture from the
|
|
585
617
|
// raw stdin bytes — Ink reports the Backspace key (\x7f) and the Delete key
|
|
586
618
|
// (ESC[3~) both as `key.delete`, so handling `key.delete` in useInput would
|
|
@@ -589,6 +621,13 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
589
621
|
const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
|
|
590
622
|
edit(text, nextCursor)
|
|
591
623
|
})
|
|
624
|
+
useCursorKeyCapture(true, (intent) => {
|
|
625
|
+
const current = valueRef.current
|
|
626
|
+
const at = clampCursor(current, cursorRef.current)
|
|
627
|
+
const next = intent === 'home' ? 0 : intent === 'end' ? current.length
|
|
628
|
+
: intent === 'backward-word' ? previousWordBoundary(current, at) : nextWordBoundary(current, at)
|
|
629
|
+
moveCursor(next)
|
|
630
|
+
})
|
|
592
631
|
|
|
593
632
|
const filtered = useMemo(() => {
|
|
594
633
|
const match = /^(\w*)$/.exec(value.slice(1))
|
|
@@ -771,7 +810,19 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
771
810
|
}
|
|
772
811
|
return
|
|
773
812
|
}
|
|
774
|
-
if (key.ctrl && input === 'c') {
|
|
813
|
+
if (key.ctrl && input === 'c') {
|
|
814
|
+
if (typing) { void onStop(); return }
|
|
815
|
+
// Idle Ctrl+C exits, but guard against an accidental single press.
|
|
816
|
+
if (confirmQuitRef.current) { onQuit(); return }
|
|
817
|
+
confirmQuitRef.current = true
|
|
818
|
+
setConfirmQuit(true)
|
|
819
|
+
if (confirmQuitTimer.current) clearTimeout(confirmQuitTimer.current)
|
|
820
|
+
confirmQuitTimer.current = setTimeout(() => {
|
|
821
|
+
confirmQuitRef.current = false
|
|
822
|
+
setConfirmQuit(false)
|
|
823
|
+
}, 2000)
|
|
824
|
+
return
|
|
825
|
+
}
|
|
775
826
|
// Physical Backspace/Delete keys are handled by useDeleteKeyCapture above
|
|
776
827
|
// (raw stdin bytes distinguish them; Ink maps both to `key.delete`). Only
|
|
777
828
|
// the unambiguous logical editing bindings stay here.
|
|
@@ -790,6 +841,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
790
841
|
edit(text, nextCursor)
|
|
791
842
|
return
|
|
792
843
|
}
|
|
844
|
+
if (key.ctrl && (key.leftArrow || key.rightArrow)) return
|
|
793
845
|
if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
|
|
794
846
|
if (key.rightArrow) { moveCursor(nextCursorBoundary(current, at)); return }
|
|
795
847
|
|
|
@@ -885,7 +937,9 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
885
937
|
})}
|
|
886
938
|
</Box>
|
|
887
939
|
<Box justifyContent="space-between">
|
|
888
|
-
|
|
940
|
+
{confirmQuit
|
|
941
|
+
? <Text color="yellowBright" bold>请再次按下Ctrl+C退出</Text>
|
|
942
|
+
: <Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>}
|
|
889
943
|
<Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
|
|
890
944
|
</Box>
|
|
891
945
|
</Box>
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import React, { useEffect, useRef, useState } from 'react'
|
|
23
23
|
import { Box, Text } from 'ink'
|
|
24
|
-
import { Select, TextInput, Spinner, type SelectItem } from './primitives.js'
|
|
24
|
+
import { isEscapeKeypress, Select, TextInput, Spinner, useStableInput, type SelectItem } from './primitives.js'
|
|
25
25
|
import { MobiusClient } from '../api.js'
|
|
26
26
|
import {
|
|
27
27
|
bindCwdToProject, cwd, getCwdPreference, loadDir2Project, loadProjectsCache,
|
|
@@ -118,8 +118,8 @@ export function ConfigFlow({ client, issue, onDone }: {
|
|
|
118
118
|
: <Select
|
|
119
119
|
items={models.map(o => ({
|
|
120
120
|
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
121
|
-
value: o.key,
|
|
122
121
|
desc: o.sub,
|
|
122
|
+
value: o.key,
|
|
123
123
|
}))}
|
|
124
124
|
onSelect={key => void pickModel(key)}
|
|
125
125
|
/>}
|
|
@@ -134,9 +134,10 @@ export function ConfigFlow({ client, issue, onDone }: {
|
|
|
134
134
|
|
|
135
135
|
type ReconfigStep = 'projects' | 'issues' | 'models' | 'creating'
|
|
136
136
|
|
|
137
|
-
export function ReconfigFlow({ client, onDone }: {
|
|
137
|
+
export function ReconfigFlow({ client, onDone, onCancel }: {
|
|
138
138
|
client: MobiusClient
|
|
139
139
|
onDone: (r: ConfigResult) => void
|
|
140
|
+
onCancel: () => void
|
|
140
141
|
}) {
|
|
141
142
|
const [step, setStep] = useState<ReconfigStep>('projects')
|
|
142
143
|
const [projects, setProjects] = useState<Project[] | null>(null)
|
|
@@ -151,6 +152,29 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
151
152
|
const doneRef = useRef(false)
|
|
152
153
|
const thisCwd = cwd()
|
|
153
154
|
|
|
155
|
+
function goBack() {
|
|
156
|
+
// The focused TextInput owns Esc while a create form is open. Returning
|
|
157
|
+
// here prevents the same raw key from also navigating the underlying step.
|
|
158
|
+
if (createMode !== null) return
|
|
159
|
+
if (step === 'models') {
|
|
160
|
+
setIssue(null)
|
|
161
|
+
setStep('issues')
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if (step === 'issues') {
|
|
165
|
+
setProject(null)
|
|
166
|
+
setIssue(null)
|
|
167
|
+
setStep('projects')
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (step === 'projects') onCancel()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Own Esc across both loaded lists and their async loading frames.
|
|
174
|
+
useStableInput((input, key) => {
|
|
175
|
+
if (isEscapeKeypress(input, key)) goBack()
|
|
176
|
+
})
|
|
177
|
+
|
|
154
178
|
useEffect(() => () => { doneRef.current = true }, [])
|
|
155
179
|
|
|
156
180
|
// Load projects on mount.
|
|
@@ -320,7 +344,7 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
320
344
|
: <Select
|
|
321
345
|
items={[
|
|
322
346
|
{ label: '➕ 创建新项目', value: '__create__' },
|
|
323
|
-
...projects.map(p => ({ label: p.name,
|
|
347
|
+
...projects.map(p => ({ label: p.name, desc: p.description, value: p.id })),
|
|
324
348
|
]}
|
|
325
349
|
initialActive={projects.length > 0 ? 1 : 0}
|
|
326
350
|
onSelect={v => v === '__create__' ? setCreateMode('project') : pickProject(projects!.find(p => p.id === v)!)}
|
|
@@ -344,7 +368,7 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
344
368
|
: <Select
|
|
345
369
|
items={[
|
|
346
370
|
{ label: '➕ 创建新任务', value: '__create__' },
|
|
347
|
-
...issues.map(i => ({ label: i.title,
|
|
371
|
+
...issues.map(i => ({ label: i.title, desc: i.description, value: i.id })),
|
|
348
372
|
]}
|
|
349
373
|
initialActive={1}
|
|
350
374
|
onSelect={v => v === '__create__' ? setCreateMode('issue') : pickIssue(issues!.find(i => i.id === v)!)} />}
|
|
@@ -365,8 +389,8 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
365
389
|
: <Select
|
|
366
390
|
items={models.map(o => ({
|
|
367
391
|
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
368
|
-
value: o.key,
|
|
369
392
|
desc: o.sub,
|
|
393
|
+
value: o.key,
|
|
370
394
|
}))}
|
|
371
395
|
onSelect={key => void pickModel(key)}
|
|
372
396
|
/>}
|
package/src/components/Login.tsx
CHANGED
|
@@ -15,12 +15,14 @@ import { saveLogin, type LoginRecord } from '../config.js'
|
|
|
15
15
|
|
|
16
16
|
const DEFAULT_SERVER = ''
|
|
17
17
|
|
|
18
|
-
export function LoginScreen({ onSuccess, onError }: {
|
|
18
|
+
export function LoginScreen({ onSuccess, onError, initialServer = DEFAULT_SERVER, initialUsername = '' }: {
|
|
19
19
|
onSuccess: (rec: LoginRecord) => void
|
|
20
20
|
onError?: (msg: string) => void
|
|
21
|
+
initialServer?: string
|
|
22
|
+
initialUsername?: string
|
|
21
23
|
}) {
|
|
22
|
-
const [server, setServer] = useState(
|
|
23
|
-
const [username, setUsername] = useState(
|
|
24
|
+
const [server, setServer] = useState(initialServer)
|
|
25
|
+
const [username, setUsername] = useState(initialUsername)
|
|
24
26
|
const [password, setPassword] = useState('')
|
|
25
27
|
const [pwdRequired, setPwdRequired] = useState<boolean | null>(null)
|
|
26
28
|
const [focus, setFocus] = useState(0) // 0 server, 1 user, 2 password
|