@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.34",
3
+ "version": "0.3.42",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
@@ -13,8 +13,8 @@ set -euo pipefail
13
13
 
14
14
  TAG=20241002
15
15
  PYVER=3.12.7
16
- BUNDLE_VER=2
17
- AIMUX_VERSION=0.1.21
16
+ BUNDLE_VER=3
17
+ AIMUX_VERSION=0.1.22
18
18
  PYPI_INDEX=https://pypi.org/simple
19
19
  WORK="${WORK:-/home/tianyi/python-bundles}"
20
20
  DIST="${DIST:-$WORK/dist}"
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 = '2'
92
- const BUNDLE_AIMUX_VERSION = '0.1.21'
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 = os.hostname().toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32)
305
- return `tui-${host || 'pc'}`
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. The TUI can launch AIMUX
310
- * through either a venv executable or bundled Python; both paths must request
311
- * hidden Windows shells or every remote command flashes a console and steals
312
- * keyboard focus from the TUI.
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' ? ['--silent-shell'] : []),
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 })
@@ -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 || handlerRef.current.reconfigOpen) {
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 navigationHint = viewport.hasOlder
213
- ? `${viewport.hasNewer ? '↑ 较早内容 · ↓ 较新内容' : '↑ 还有较早内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
214
- : `${viewport.hasNewer ? '已到最早 · ↓ 还有较新内容' : '全部内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
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"> {navigationHint}</Text></Box>
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') { typing ? void onStop() : onQuit(); return }
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
- <Text dimColor>{(stdout.columns ?? 80) >= 58 ? 'Enter 发送 · Shift+Enter / Ctrl+J 换行' : 'Enter 发送 · Ctrl+J 换行'}</Text>
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, value: p.id, desc: p.description })),
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, value: i.id, desc: i.description })),
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
  />}
@@ -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(DEFAULT_SERVER)
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