@mobius-os/mobius 0.3.34 → 0.3.38

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.38",
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,16 +301,24 @@ 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
316
  * Build the reverse-connect command in one place. The TUI can launch AIMUX
310
317
  * 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.
318
+ * a fully headless Windows shell or every remote command flashes a console and
319
+ * steals keyboard focus from the TUI. Keep the old --silent-shell path
320
+ * available for older AIMUX bundles; current bundles use the no-console v2
321
+ * implementation (the historical spelling --slient-v2 is intentional).
313
322
  */
314
323
  export function reverseConnectArgs(
315
324
  server: string,
@@ -322,7 +331,7 @@ export function reverseConnectArgs(
322
331
  '--identifier', identifier,
323
332
  '--token', token,
324
333
  '--replace',
325
- ...(platform === 'win32' ? ['--silent-shell'] : []),
334
+ ...(platform === 'win32' ? ['--slient-v2'] : []),
326
335
  ]
327
336
  }
328
337
 
@@ -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
@@ -57,11 +59,12 @@ const SLASH_COMMANDS = [
57
59
  { cmd: '/resume', desc: '恢复一个历史会话' },
58
60
  { cmd: '/model', desc: '更换模型并开启新会话(保留当前任务)' },
59
61
  { cmd: '/config', desc: '重新选择项目、任务和模型' },
62
+ { cmd: '/logout', desc: '断开当前连接并返回登录界面' },
60
63
  { cmd: '/help', desc: '显示帮助' },
61
64
  { cmd: '/quit', desc: '退出 TUI' },
62
65
  ]
63
66
 
64
- export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
67
+ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onLogout, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
65
68
  const chat = useChat({ client, ready, resumeSessionId })
66
69
  const [showHelp, setShowHelp] = useState(false)
67
70
  // null means "follow the tail". A concrete anchor identifies the exact row
@@ -91,10 +94,11 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
91
94
  case '/help': setShowHelp(s => !s); return true
92
95
  case '/model': setConfigOpen(true); return true
93
96
  case '/config': setReconfigOpen(true); return true
97
+ case '/logout': onLogout(); return true
94
98
  case '/quit': case '/exit': onQuit(); return true
95
99
  default: return false
96
100
  }
97
- }, [onClear, onResume, onQuit])
101
+ }, [onClear, onResume, onQuit, onLogout])
98
102
 
99
103
  const onSubmit = useCallback((text: string) => {
100
104
  const t = text.trim()
@@ -201,10 +205,11 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
201
205
  // the keypress). configOpen/reconfigOpen/sessionId are read from handlerRef
202
206
  // (see above) because Ink keeps the originally-registered callback and would
203
207
  // otherwise see a stale closure.
204
- if (handlerRef.current.configOpen || handlerRef.current.reconfigOpen) {
208
+ if (handlerRef.current.configOpen) {
205
209
  if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
206
210
  return
207
211
  }
212
+ if (handlerRef.current.reconfigOpen) return // ReconfigFlow owns hierarchical Esc navigation.
208
213
  if (key.pageUp) scrollRows(-pageRows)
209
214
  else if (key.pageDown) scrollRows(pageRows)
210
215
  }, { interactive: false })
@@ -310,6 +315,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
310
315
  <ReconfigFlow
311
316
  client={client}
312
317
  onDone={(result) => onReconfigure(result)}
318
+ onCancel={() => onConfigCancel(chat.sessionId)}
313
319
  />
314
320
  </Box>
315
321
  )
@@ -589,6 +595,13 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
589
595
  const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
590
596
  edit(text, nextCursor)
591
597
  })
598
+ useCursorKeyCapture(true, (intent) => {
599
+ const current = valueRef.current
600
+ const at = clampCursor(current, cursorRef.current)
601
+ const next = intent === 'home' ? 0 : intent === 'end' ? current.length
602
+ : intent === 'backward-word' ? previousWordBoundary(current, at) : nextWordBoundary(current, at)
603
+ moveCursor(next)
604
+ })
592
605
 
593
606
  const filtered = useMemo(() => {
594
607
  const match = /^(\w*)$/.exec(value.slice(1))
@@ -790,6 +803,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
790
803
  edit(text, nextCursor)
791
804
  return
792
805
  }
806
+ if (key.ctrl && (key.leftArrow || key.rightArrow)) return
793
807
  if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
794
808
  if (key.rightArrow) { moveCursor(nextCursorBoundary(current, at)); return }
795
809
 
@@ -885,7 +899,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
885
899
  })}
886
900
  </Box>
887
901
  <Box justifyContent="space-between">
888
- <Text dimColor>{(stdout.columns ?? 80) >= 58 ? 'Enter 发送 · Shift+Enter / Ctrl+J 换行' : 'Enter 发送 · Ctrl+J 换行'}</Text>
902
+ <Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>
889
903
  <Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
890
904
  </Box>
891
905
  </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
@@ -230,10 +230,75 @@ function toItems(arr: { id: string; name: string; description?: string }[]): Sel
230
230
  return arr.map(s => ({ label: s.name, value: s.id, desc: s.description }))
231
231
  }
232
232
 
233
- // 把可能含换行的描述压成单行:换行 可见符号 ⏎,避免列表项跨行。
234
- function flattenDesc(s?: string): string {
235
- if (!s) return ''
236
- return s.replace(/\s*\n\s*/g, ' ').replace(/[ \t]+/g, ' ').trim()
233
+ // Search-first picker used by the project and issue screens. The search field
234
+ // owns the initial focus so a user can type immediately; Down hands control to
235
+ // the normal Select for keyboard navigation. Enter in the search field chooses
236
+ // the first matching row, which keeps the common "type a unique name, Enter"
237
+ // workflow to one step.
238
+ function SearchableSelect({
239
+ items,
240
+ createItem,
241
+ title,
242
+ placeholder,
243
+ onSelect,
244
+ onCreate,
245
+ onQuit,
246
+ }: {
247
+ items: SelectItem[]
248
+ createItem?: SelectItem
249
+ title: string
250
+ placeholder: string
251
+ onSelect: (value: string) => void
252
+ onCreate?: () => void
253
+ onQuit?: () => void
254
+ }) {
255
+ const [query, setQuery] = useState('')
256
+ const [focus, setFocus] = useState<'search' | 'list'>('search')
257
+ const needle = query.trim().toLocaleLowerCase()
258
+ const matches = (item: SelectItem) => {
259
+ if (!needle) return true
260
+ return `${item.label}\n${item.desc ?? ''}\n${item.value}`.toLocaleLowerCase().includes(needle)
261
+ }
262
+ const filtered = items.filter(matches)
263
+ const createMatches = createItem && matches(createItem)
264
+ const visibleItems = createItem && (!needle || createMatches)
265
+ ? [createItem, ...filtered]
266
+ : filtered
267
+
268
+ function choose(value: string) {
269
+ if (value === createItem?.value) onCreate?.()
270
+ else onSelect(value)
271
+ }
272
+
273
+ return (
274
+ <Box flexDirection="column">
275
+ <Text bold color="cyan">{title}</Text>
276
+ <Box marginTop={1} flexDirection="column">
277
+ <TextInput
278
+ value={query}
279
+ onChange={setQuery}
280
+ focused={focus === 'search'}
281
+ prompt="搜索:"
282
+ placeholder={placeholder}
283
+ onArrowDown={() => setFocus('list')}
284
+ onArrowUp={() => setFocus('list')}
285
+ onSubmit={() => { if (visibleItems.length) choose(visibleItems[0].value) }}
286
+ onEscape={() => onQuit?.()}
287
+ />
288
+ {query.trim() && !filtered.length
289
+ ? <Text color="yellow">没有匹配的项目,请修改搜索</Text>
290
+ : focus === 'search' && query.trim() ? <Text color="gray">匹配 {filtered.length} 项 · ↓进入列表</Text> : null}
291
+ {focus === 'list' && !visibleItems.length
292
+ ? <Text color="yellow">没有匹配的项目,请按 Esc 修改搜索</Text>
293
+ : <Select
294
+ items={visibleItems}
295
+ focused={focus === 'list'}
296
+ onBack={() => setFocus('search')}
297
+ onSelect={choose}
298
+ />}
299
+ </Box>
300
+ </Box>
301
+ )
237
302
  }
238
303
 
239
304
  // ── Project picker ───────────────────────────────────────────────────────────
@@ -264,22 +329,25 @@ function ProjectPicker({ cwd, projects, statusMsg, onPick, onCreate, onQuit }: {
264
329
  )
265
330
  }
266
331
 
267
- const items: SelectItem[] = [
268
- { label: '➕ 创建新项目', value: '__create__', desc: '绑定到当前路径' },
269
- ...projects.map(p => {
270
- const desc = flattenDesc(p.description)
271
- return { label: desc ? `${p.name} — ${desc}` : p.name, value: p.id }
272
- }),
273
- ]
332
+ const items: SelectItem[] = projects.map(p => ({
333
+ label: p.name,
334
+ desc: p.description,
335
+ value: p.id,
336
+ }))
274
337
  return (
275
338
  <Box flexDirection="column" paddingX={2} paddingY={1}>
276
- <Text bold color="cyan">选择当前路径的绑定项目</Text>
277
339
  <Text color="gray">{cwd}</Text>
278
- <Box marginTop={1}>
279
- <Select items={items} onBack={onQuit} onSelect={v => v === '__create__' ? setMode('create') : onPick(projects.find(p => p.id === v)!)} />
280
- </Box>
340
+ <SearchableSelect
341
+ title="选择当前路径的绑定项目"
342
+ placeholder="输入项目名或描述"
343
+ items={items}
344
+ createItem={{ label: '➕ 创建新项目', value: '__create__', desc: '绑定到当前路径' }}
345
+ onSelect={v => onPick(projects.find(p => p.id === v)!)}
346
+ onCreate={() => setMode('create')}
347
+ onQuit={onQuit}
348
+ />
281
349
  {statusMsg ? <Text color="yellow">{statusMsg}</Text> : null}
282
- <Text color="gray">↑↓ 选择 · 回车确认 · Esc 退出</Text>
350
+ <Text color="gray">输入关键词筛选 · ↓进入列表 · Esc 退出</Text>
283
351
  </Box>
284
352
  )
285
353
  }
@@ -306,18 +374,23 @@ function IssuePicker({ issues, onPick, onCreate }: {
306
374
  </Box>
307
375
  )
308
376
  }
309
- const items: SelectItem[] = [
310
- { label: '➕ 创建新任务', value: '__create__' },
311
- ...issues.map(i => ({ label: i.title, value: i.id, desc: i.description })),
312
- ]
377
+ const items: SelectItem[] = issues.map(i => ({
378
+ label: i.title,
379
+ desc: i.description,
380
+ value: i.id,
381
+ }))
313
382
  return (
314
383
  <Box flexDirection="column">
315
- <Text bold color="cyan">选择任务(Issue)</Text>
316
384
  <Text color="gray">偏好设置将保存在所选任务内部</Text>
317
385
  <Box marginTop={1}>
318
- {issues.length === 0 && mode === 'list'
319
- ? <Select items={[{ label: '➕ 创建新任务(尚无任务)', value: '__create__' }]} onSelect={() => setMode('create-name')} />
320
- : <Select items={items} onSelect={v => v === '__create__' ? setMode('create-name') : onPick(issues.find(i => i.id === v)!)} />}
386
+ <SearchableSelect
387
+ title="选择任务(Issue)"
388
+ placeholder="输入任务标题或描述"
389
+ items={items}
390
+ createItem={{ label: issues.length ? '➕ 创建新任务' : '➕ 创建新任务(尚无任务)', value: '__create__' }}
391
+ onSelect={v => onPick(issues.find(i => i.id === v)!)}
392
+ onCreate={() => setMode('create-name')}
393
+ />
321
394
  </Box>
322
395
  </Box>
323
396
  )
@@ -332,8 +405,8 @@ function ModelPicker({ options, defaultKey, onSelect }: {
332
405
  if (!options.length) return <Text color="gray">加载模型列表…</Text>
333
406
  const items: SelectItem[] = options.map(o => ({
334
407
  label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
335
- value: o.key,
336
408
  desc: o.sub,
409
+ value: o.key,
337
410
  }))
338
411
  return (
339
412
  <Box flexDirection="column">
@@ -4,7 +4,8 @@
4
4
  */
5
5
  import React, { useEffect, useRef, useState } from 'react'
6
6
  import { Box, Text, useInput, useStdout, useStdin, type Key } from 'ink'
7
- import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
7
+ import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousWordBoundary, nextWordBoundary } from '../lib/delete-keys.js'
8
+ import { useCursorKeyCapture } from '../lib/cursor-keys.js'
8
9
 
9
10
  /** Return false when a mounted listener deliberately did not consume the input. */
10
11
  type InputHandler = (input: string, key: Key) => void | false
@@ -318,6 +319,14 @@ export function TextInput(props: TextInputProps) {
318
319
  const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
319
320
  edit(text, nextCursor)
320
321
  })
322
+ useCursorKeyCapture(focused, (intent) => {
323
+ const current = valueRef.current
324
+ const at = clampCursor(current, cursorRef.current)
325
+ const next = intent === 'home' ? 0 : intent === 'end' ? current.length
326
+ : intent === 'backward-word' ? previousWordBoundary(current, at) : nextWordBoundary(current, at)
327
+ cursorRef.current = next
328
+ setCursor(next)
329
+ })
321
330
 
322
331
  useStableInput((input, key) => {
323
332
  if (isMouseInput(input)) return
@@ -343,6 +352,7 @@ export function TextInput(props: TextInputProps) {
343
352
  edit(text, nextCursor)
344
353
  return
345
354
  }
355
+ if (key.ctrl && (key.leftArrow || key.rightArrow)) return
346
356
  if (key.leftArrow) { setCursor(c => Math.max(0, c - 1)); return }
347
357
  if (key.rightArrow) { setCursor(c => Math.min(value.length, c + 1)); return }
348
358
  if (key.ctrl && input === 'a') { setCursor(0); return }
@@ -421,6 +431,12 @@ export interface SelectItem {
421
431
  desc?: string
422
432
  }
423
433
 
434
+ /** Keep a picker explanation on the item's main row; Select truncates that row. */
435
+ export function inlineSelectLabel(label: string, detail?: string): string {
436
+ const oneLine = detail?.replace(/\s*\n\s*/g, ' ⏎ ').replace(/[ \t]+/g, ' ').trim()
437
+ return oneLine ? `${label} - ${oneLine}` : label
438
+ }
439
+
424
440
  export interface SelectProps {
425
441
  items: SelectItem[]
426
442
  mode?: 'single' | 'multi'
@@ -491,17 +507,20 @@ export function Select(props: SelectProps) {
491
507
  const isActive = realIdx === active
492
508
  const checked = mode === 'multi' ? selectedSet.has(it.value) : false
493
509
  const marker = mode === 'multi' ? (checked ? '☑' : '☐') : isActive ? '❯' : ' '
510
+ // Keep picker rows compact: descriptions belong on the highlighted
511
+ // row only. Unfocused rows show just their label so a long list does
512
+ // not turn every item into a multi-line block.
513
+ const rowLabel = isActive ? inlineSelectLabel(it.label, it.desc) : it.label
494
514
  return (
495
- <Box key={it.value} flexDirection="column">
515
+ <Box key={it.value}>
496
516
  <Text
497
517
  color={isActive ? 'black' : undefined}
498
518
  backgroundColor={isActive ? 'cyan' : undefined}
499
519
  bold={isActive}
500
520
  wrap="truncate-end"
501
521
  >
502
- {marker} {it.label}
522
+ {marker} {rowLabel}
503
523
  </Text>
504
- {isActive && it.desc ? <Text color="gray" wrap="truncate-end"> {it.desc}</Text> : null}
505
524
  </Box>
506
525
  )
507
526
  })}
@@ -0,0 +1,70 @@
1
+ /** Raw terminal cursor-key capture for keys Ink does not expose in `Key`. */
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import { useStdin } from 'ink'
5
+
6
+ export type CursorIntent = 'home' | 'end' | 'backward-word' | 'forward-word'
7
+
8
+ // Home/End vary by terminal/application mode. Ctrl+Left/Right are the xterm
9
+ // CSI modifier-5 forms; the shorter 5D/5C forms are emitted by some tmux and
10
+ // ConPTY bridges. Longest sequences come first so prefixes cannot win early.
11
+ const CURSOR_SEQUENCES: ReadonlyArray<readonly [string, CursorIntent]> = [
12
+ ['\x1b[1;5D', 'backward-word'],
13
+ ['\x1b[1;5C', 'forward-word'],
14
+ ['\x1b[5D', 'backward-word'],
15
+ ['\x1b[5C', 'forward-word'],
16
+ ['\x1b[1~', 'home'],
17
+ ['\x1b[7~', 'home'],
18
+ ['\x1b[4~', 'end'],
19
+ ['\x1b[8~', 'end'],
20
+ ['\x1b[H', 'home'],
21
+ ['\x1b[F', 'end'],
22
+ ['\x1bOH', 'home'],
23
+ ['\x1bOF', 'end'],
24
+ ]
25
+
26
+ export function classifyCursorSequence(raw: string): { intent: CursorIntent; length: number } | null {
27
+ for (const [sequence, intent] of CURSOR_SEQUENCES) {
28
+ if (raw.startsWith(sequence)) return { intent, length: sequence.length }
29
+ }
30
+ return null
31
+ }
32
+
33
+ function isCursorPrefix(raw: string): boolean {
34
+ return CURSOR_SEQUENCES.some(([sequence]) => sequence.startsWith(raw))
35
+ }
36
+
37
+ /** Capture physical Home/End and Ctrl+arrow bytes without relying on Ink's lossy key object. */
38
+ export function useCursorKeyCapture(
39
+ enabled: boolean,
40
+ onCursor: (intent: CursorIntent) => void,
41
+ ): void {
42
+ const { internal_eventEmitter } = useStdin()
43
+ const enabledRef = useRef(enabled)
44
+ const onCursorRef = useRef(onCursor)
45
+ enabledRef.current = enabled
46
+ onCursorRef.current = onCursor
47
+
48
+ useEffect(() => {
49
+ if (!internal_eventEmitter) return
50
+ let buf = ''
51
+ const handler = (chunk: unknown) => {
52
+ buf += String(chunk)
53
+ while (buf) {
54
+ const match = classifyCursorSequence(buf)
55
+ if (match) {
56
+ buf = buf.slice(match.length)
57
+ if (enabledRef.current) onCursorRef.current(match.intent)
58
+ continue
59
+ }
60
+ if (isCursorPrefix(buf)) break
61
+ // Ordinary text is handled by Ink's useInput; only retain a possible
62
+ // incomplete escape prefix so split terminal sequences still match.
63
+ const nextEsc = buf.indexOf('\x1b', buf.startsWith('\x1b') ? 1 : 0)
64
+ buf = nextEsc >= 0 ? buf.slice(nextEsc) : ''
65
+ }
66
+ }
67
+ internal_eventEmitter.on('input', handler)
68
+ return () => { internal_eventEmitter.off('input', handler) }
69
+ }, [internal_eventEmitter])
70
+ }
@@ -59,7 +59,7 @@ export function nextCursorBoundary(text: string, cursor: number): number {
59
59
  }
60
60
 
61
61
  /** Backward-word boundary: skip trailing whitespace, then the word before it. */
62
- function backwardWordBoundary(text: string, at: number): number {
62
+ export function previousWordBoundary(text: string, at: number): number {
63
63
  let i = at
64
64
  while (i > 0 && /\s/.test(text[i - 1])) i--
65
65
  while (i > 0 && !/\s/.test(text[i - 1])) i--
@@ -67,7 +67,7 @@ function backwardWordBoundary(text: string, at: number): number {
67
67
  }
68
68
 
69
69
  /** Forward-word boundary: skip leading whitespace, then the word after it. */
70
- function forwardWordBoundary(text: string, at: number): number {
70
+ export function nextWordBoundary(text: string, at: number): number {
71
71
  let i = at
72
72
  while (i < text.length && /\s/.test(text[i])) i++
73
73
  while (i < text.length && !/\s/.test(text[i])) i++
@@ -93,12 +93,12 @@ export function applyDeleteIntent(
93
93
  return { text: text.slice(0, at) + text.slice(next), cursor: at }
94
94
  }
95
95
  case 'backward-word': {
96
- const start = backwardWordBoundary(text, at)
96
+ const start = previousWordBoundary(text, at)
97
97
  if (start === at) return { text, cursor: at }
98
98
  return { text: text.slice(0, start) + text.slice(at), cursor: start }
99
99
  }
100
100
  case 'forward-word': {
101
- const end = forwardWordBoundary(text, at)
101
+ const end = nextWordBoundary(text, at)
102
102
  if (end === at) return { text, cursor: at }
103
103
  return { text: text.slice(0, at) + text.slice(end), cursor: at }
104
104
  }
package/src/markdown.ts CHANGED
@@ -12,6 +12,30 @@ import chalk from 'chalk'
12
12
  import { highlight, supportsLanguage } from 'cli-highlight'
13
13
  import { lexer, type Token, type Tokens } from 'marked'
14
14
 
15
+ /**
16
+ * Decode HTML entities that marked's lexer injects into text tokens.
17
+ * marked encodes `' " < > &` as `&#39; &quot; &lt; &gt; &amp;` even when
18
+ * only lexing (not rendering to HTML). The TUI renders to a terminal so
19
+ * we must reverse that encoding ourselves.
20
+ */
21
+ const HTML_ENTITY_RE = /&(?:#(x?)([0-9a-fA-F]+)|(amp|lt|gt|quot|#39));/g
22
+ function decodeHtmlEntities(s: string): string {
23
+ return s.replace(HTML_ENTITY_RE, (_, hex: string | undefined, num: string, named: string | undefined) => {
24
+ if (named) {
25
+ switch (named) {
26
+ case 'amp': return '&'
27
+ case 'lt': return '<'
28
+ case 'gt': return '>'
29
+ case 'quot': return '"'
30
+ case '#39': return "'"
31
+ default: return _
32
+ }
33
+ }
34
+ const code = parseInt(num, hex ? 16 : 10)
35
+ return String.fromCodePoint(code)
36
+ })
37
+ }
38
+
15
39
  export interface RenderedMarkdownLine {
16
40
  text: string
17
41
  code: boolean
@@ -43,7 +67,7 @@ function renderInlineOne(t: Token): string {
43
67
  const anyT = t as any
44
68
  switch (t.type) {
45
69
  case 'text':
46
- return anyT.tokens ? renderInline(anyT.tokens) : escapeAnsiReset(anyT.text)
70
+ return anyT.tokens ? renderInline(anyT.tokens) : escapeAnsiReset(decodeHtmlEntities(anyT.text))
47
71
  case 'strong':
48
72
  return chalk.bold(renderInline(anyT.tokens))
49
73
  case 'em':
@@ -51,20 +75,20 @@ function renderInlineOne(t: Token): string {
51
75
  case 'del':
52
76
  return chalk.dim.strikethrough(renderInline(anyT.tokens))
53
77
  case 'codespan':
54
- return chalk.cyanBright(anyT.text)
78
+ return chalk.cyanBright(decodeHtmlEntities(anyT.text))
55
79
  case 'link': {
56
80
  const label = renderInline(anyT.tokens) || anyT.href
57
81
  return anyT.href && label !== anyT.href ? `${chalk.cyan(label)} (${chalk.dim.underline(anyT.href)})` : chalk.cyan(label)
58
82
  }
59
83
  case 'image':
60
- return chalk.magentaBright(`[图片: ${anyT.href || anyT.text}]`)
84
+ return chalk.magentaBright(`[图片: ${anyT.href || decodeHtmlEntities(anyT.text)}]`)
61
85
  case 'br':
62
86
  return '\n'
63
87
  case 'escape':
64
88
  case 'html':
65
- return anyT.text ?? ''
89
+ return anyT.text ? decodeHtmlEntities(anyT.text) : ''
66
90
  default:
67
- return anyT.text ?? renderInline(anyT.tokens)
91
+ return anyT.text ? decodeHtmlEntities(anyT.text) : renderInline(anyT.tokens)
68
92
  }
69
93
  }
70
94
 
@@ -74,7 +98,7 @@ function escapeAnsiReset(s: string): string {
74
98
  }
75
99
 
76
100
  function renderTable(t: Tokens.Table): string {
77
- const cell = (toks: any) => renderInline(toks?.tokens ?? [{ type: 'text', text: toks?.text ?? '' }])
101
+ const cell = (toks: any) => renderInline(toks?.tokens ?? [{ type: 'text', text: decodeHtmlEntities(toks?.text ?? '') }])
78
102
  const header = t.header.map((h) => cell(h)).join(' | ')
79
103
  const rows = t.rows.map((r) => r.map((c) => cell(c)).join(' | ')).join('\n')
80
104
  return chalk.bold(header) + '\n' + chalk.dim('-'.repeat(Math.min(header.length, 80))) + '\n' + rows
@@ -121,7 +145,7 @@ function renderBlock(t: Token): string {
121
145
  case 'paragraph':
122
146
  return renderInline(anyT.tokens)
123
147
  case 'code': {
124
- return renderCode(anyT.text, anyT.lang)
148
+ return renderCode(decodeHtmlEntities(anyT.text), anyT.lang)
125
149
  }
126
150
  case 'blockquote': {
127
151
  const inner = (anyT.tokens as Token[]).map(renderBlock).join('\n')
@@ -142,9 +166,9 @@ function renderBlock(t: Token): string {
142
166
  case 'space':
143
167
  return ''
144
168
  case 'html':
145
- return chalk.dim(anyT.text ?? '')
169
+ return chalk.dim(decodeHtmlEntities(anyT.text ?? ''))
146
170
  default:
147
- return anyT.text ?? renderInline(anyT.tokens)
171
+ return anyT.text ? decodeHtmlEntities(anyT.text) : renderInline(anyT.tokens)
148
172
  }
149
173
  }
150
174
 
@@ -156,5 +180,5 @@ function renderListItemBody(item: any): string {
156
180
  .filter(Boolean)
157
181
  .join('\n')
158
182
  }
159
- return item.text ?? ''
183
+ return item.text ? decodeHtmlEntities(item.text) : ''
160
184
  }
@@ -7,7 +7,7 @@ import os from 'node:os'
7
7
  import path from 'node:path'
8
8
  import { render } from 'ink-testing-library'
9
9
  import { AimuxStatusLine } from '../src/components/AimuxStatus.js'
10
- import { AimuxSupervisor, probeAimuxBridgeConnection, bundleArch, bundleUrl, spawnLauncher, ensureFromBundle, downloadBundleForTest, reverseConnectArgs, aimuxLogPath, bundleHealthCheckCode } from '../src/aimux.js'
10
+ import { AimuxSupervisor, probeAimuxBridgeConnection, bundleArch, bundleUrl, spawnLauncher, ensureFromBundle, downloadBundleForTest, reverseConnectArgs, aimuxLogPath, bundleHealthCheckCode, tuiAimuxIdentifier } from '../src/aimux.js'
11
11
 
12
12
  const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
13
13
  let pass = 0, fail = 0
@@ -78,11 +78,11 @@ async function testBundleArchAndUrl() {
78
78
  const arch = bundleArch()
79
79
  ok(arch === 'linux-x64' || arch === 'win-x64' || arch === 'mac-x64', `bundleArch returns a supported arch on this host (${arch})`)
80
80
  const before = bundleUrl('linux-x64')
81
- ok(before.includes('mobius-python-linux-x64-v2') && before.endsWith('.zip'), 'bundleUrl follows the fixed filename pattern')
81
+ ok(before.includes('mobius-python-linux-x64-v3') && before.endsWith('.zip'), 'bundleUrl follows the fixed filename pattern')
82
82
  const saved = process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL
83
83
  process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL = 'https://example.test/cdn/'
84
84
  try {
85
- ok(bundleUrl('win-x64') === 'https://example.test/cdn/mobius-python-win-x64-v2.zip', 'MOBIUS_TUI_PYTHON_BUNDLE_URL overrides the CDN base and trims trailing slash')
85
+ ok(bundleUrl('win-x64') === 'https://example.test/cdn/mobius-python-win-x64-v3.zip', 'MOBIUS_TUI_PYTHON_BUNDLE_URL overrides the CDN base and trims trailing slash')
86
86
  } finally { if (saved === undefined) delete process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL; else process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL = saved }
87
87
  }
88
88
 
@@ -136,17 +136,27 @@ function testReverseConnectArgs() {
136
136
  console.log('\n[AIMUX 6] reverse connect Windows shell visibility')
137
137
  const win = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32')
138
138
  const linux = reverseConnectArgs('https://mobius.test/', 'tui-linux', 'jwt-test', 'linux')
139
- ok(win.includes('--silent-shell'), 'Windows reverse connection always requests hidden command shells')
140
- ok(!linux.includes('--silent-shell'), 'non-Windows reverse connection does not receive the Windows-only flag')
139
+ ok(win.includes('--slient-v2'), 'Windows reverse connection always requests the no-console shell mode')
140
+ ok(!linux.includes('--slient-v2'), 'non-Windows reverse connection does not receive the Windows-only flag')
141
141
  ok(win[2] === 'https://mobius.test/aimux_bridge', 'reverse connection normalizes the bridge URL')
142
142
  }
143
143
 
144
+ function testAimuxIdentifierScopesWorkspace() {
145
+ console.log('\n[AIMUX 6a] reverse client identifier workspace isolation')
146
+ const first = tuiAimuxIdentifier('same-host', '/work/project-a')
147
+ const firstAgain = tuiAimuxIdentifier('same-host', '/work/project-a')
148
+ const second = tuiAimuxIdentifier('same-host', '/work/project-b')
149
+ ok(first === firstAgain, 'identifier is stable for the same host and workspace')
150
+ ok(first !== second, 'different workspaces on one host do not replace each other')
151
+ ok(/^tui-same-host-[a-f0-9]{10}$/.test(first), 'identifier remains bridge-safe and recognizable')
152
+ }
153
+
144
154
  function testBundleHealthCheck() {
145
155
  console.log('\n[AIMUX 6b] bundle dependency health check')
146
156
  const win = bundleHealthCheckCode('win32')
147
157
  const linux = bundleHealthCheckCode('linux')
148
158
  ok(win.includes('aimux.bridge_client') && win.includes('win32_setctime'), 'Windows bundle probe imports the real bridge path and its platform dependency')
149
- ok(win.includes("aimux.__version__ == '0.1.21'"), 'bundle probe rejects stale AIMUX versions')
159
+ ok(win.includes("aimux.__version__ == '0.1.23'"), 'bundle probe rejects stale AIMUX versions')
150
160
  ok(!linux.includes('win32_setctime'), 'non-Windows bundle probe does not require the Windows-only package')
151
161
  }
152
162
 
@@ -199,6 +209,7 @@ async function main() {
199
209
  await testBundleArchAndUrl()
200
210
  await testSpawnLauncher()
201
211
  testReverseConnectArgs()
212
+ testAimuxIdentifierScopesWorkspace()
202
213
  testBundleHealthCheck()
203
214
  await testEnsureFromBundleReady()
204
215
  await testDownloadBundleStream()
@@ -68,9 +68,9 @@ function mockFetch(url: string, init?: RequestInit): Response {
68
68
  }
69
69
  // issues
70
70
  if (url.includes('/api/projects/') && url.includes('/issues') && method === 'POST') return json({ id: IID, project_id: PID, title: '命令行任务' }) // create issue
71
- if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([{ id: IID, project_id: PID, title: '命令行任务' }]) // list issues
71
+ if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([{ id: IID, project_id: PID, title: '命令行任务', description: '任务说明' }]) // list issues
72
72
  // projects
73
- if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲' }]) // list projects
73
+ if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲', description: '项目说明' }]) // list projects
74
74
  if (url.endsWith('/api/projects') && method === 'POST') return json({ id: PID, name: '测试项目PTY' }) // create project (exact)
75
75
  // preference lookups
76
76
  if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT-5.5', title: 'GPT-5.5', sub: 'Codex', backend: 'tmux-codex' }])
@@ -165,13 +165,26 @@ async function main() {
165
165
  stdin.write('\r')
166
166
  ok(await waitFor(lastFrame, '重新配置'), '/config opens the full reconfig flow')
167
167
  ok(await waitFor(lastFrame, '选择项目'), '/config shows project picker first')
168
- // Pick the first project (created above).
168
+ ok(await waitFor(lastFrame, '已有项目甲 - 项目说明'), '/config keeps the project explanation on its main row')
169
+ // Pick the first project (created above), then verify Esc walks back one
170
+ // level at a time instead of closing the entire config flow.
169
171
  stdin.write('\r'); await delay(400)
170
172
  ok(await waitFor(lastFrame, '选择任务'), '/config shows issue picker after project')
171
- // Pick the first issue.
173
+ ok((lastFrame() ?? '').includes('命令行任务 - 任务说明'), '/config keeps the issue explanation on its main row')
174
+ stdin.write('\x1b'); await delay(180)
175
+ ok(await waitFor(lastFrame, '选择项目'), 'Esc from issue selection returns to project selection')
176
+ stdin.write('\r'); await delay(400)
177
+ ok(await waitFor(lastFrame, '选择任务'), 'project selection can be re-entered after Esc')
178
+
179
+ // Pick the issue and verify the model step also returns to the issue step.
172
180
  stdin.write('\r'); await delay(400)
173
181
  ok(await waitFor(lastFrame, '选择模型'), '/config shows model picker after issue')
174
182
  ok(await waitFor(lastFrame, 'GPT-5.5'), '/config model list rendered')
183
+ ok((lastFrame() ?? '').includes('GPT-5.5 (默认) - Codex'), '/config keeps the model explanation on its main row')
184
+ stdin.write('\x1b'); await delay(180)
185
+ ok(await waitFor(lastFrame, '选择任务'), 'Esc from model selection returns to issue selection')
186
+ stdin.write('\r'); await delay(400)
187
+ ok(await waitFor(lastFrame, '选择模型'), 'issue selection can be re-entered after Esc')
175
188
  stdin.write('\r'); await delay(700) // pick codex → create session
176
189
  ok(await waitFor(lastFrame, '输入问题'), '/config creates a fresh session and returns to chat')
177
190
  ok((lastFrame() ?? '').includes('?session=sess-1'), 'reconfigured chat is attached to the new session')
@@ -191,6 +204,14 @@ async function main() {
191
204
  ok(await waitFor(lastFrame, '输入问题'), '/model creates a fresh session and returns to chat')
192
205
  ok((lastFrame() ?? '').includes('?session=sess-1'), '/model new session attached')
193
206
  snap('7-after-model', lastFrame() ?? '')
207
+
208
+ // ── /logout ─────────────────────────────────────────────────────────────
209
+ await delay(400)
210
+ stdin.write('/logout'); await delay(150)
211
+ stdin.write('\r')
212
+ ok(await waitFor(lastFrame, 'Mobius 登录'), '/logout returns to the login form')
213
+ ok(!fs.existsSync(path.join(TMP_HOME, 'login.json')), '/logout clears the persisted login token')
214
+ ok((lastFrame() ?? '').includes('http://mock.local') && (lastFrame() ?? '').includes('tester'), '/logout keeps server and username available for the next login')
194
215
  snap('6-after-config', lastFrame() ?? '')
195
216
  } finally {
196
217
  unmount()
package/tests/ui.test.tsx CHANGED
@@ -162,7 +162,7 @@ async function testChat() {
162
162
  })
163
163
  try {
164
164
  const { stdin, lastFrame, unmount } = render(
165
- <ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
165
+ <ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
166
166
  )
167
167
  await delay(40)
168
168
  const initialFrame = lastFrame() ?? ''
@@ -226,7 +226,7 @@ async function testResumedWorkingStatus() {
226
226
  })
227
227
  try {
228
228
  const { stdin, lastFrame, unmount } = render(
229
- <ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
229
+ <ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
230
230
  )
231
231
  await delay(120)
232
232
  ok((lastFrame() ?? '').includes('Working ('), 'resuming an already-running session restores Working without a new typing event')
@@ -261,6 +261,16 @@ function testMarkdownCodeRendering() {
261
261
 
262
262
  const unlabelled = renderMarkdownLines('```\necho $HOME\n```')
263
263
  ok(unlabelled.length === 1 && unlabelled[0].text === 'echo $HOME' && unlabelled[0].code, 'unlabelled code stays plain instead of being guessed as bash')
264
+
265
+ // HTML entity decoding — marked's lexer encodes ', ", <, >, & even when
266
+ // only tokenising, so the TUI renderer must decode them back.
267
+ const entities = renderMarkdownLines("What's \"cool\"? 1 < 2 & 3 > 1")
268
+ const entityText = entities.map(r => r.text).join('\n')
269
+ ok(entityText.includes("What's"), "&#39; decoded back to apostrophe")
270
+ ok(entityText.includes('"cool"'), "&quot; decoded back to double-quote")
271
+ ok(entityText.includes('1 < 2'), "&lt; decoded back to <")
272
+ ok(entityText.includes('3 > 1'), "&gt; decoded back to >")
273
+ ok(entityText.includes('& 3'), "&amp; decoded back to &")
264
274
  }
265
275
 
266
276
  function testFirstUserEntryDedupe() {
@@ -297,11 +307,18 @@ async function testPrepRender() {
297
307
  ok(frame.includes('选择当前路径的绑定项目'), 'project picker title shown')
298
308
  ok(frame.includes('已有项目A') && frame.includes('已有项目B'), 'existing projects listed')
299
309
  ok(frame.includes('创建新项目'), 'create-new option present')
300
- // multi-line description must be flattened onto one line with ⏎ in place of \n
301
- ok(frame.includes('已有项目A 第一行 第二行'), 'multi-line description flattened to a single line')
302
- ok(frame.includes('已有项目B 单行描述'), 'single-line description kept as-is')
310
+ // Only the highlighted row carries its description; unfocused rows stay
311
+ // compact and show their names alone.
312
+ ok(!frame.includes('已有项目A - 第一行') && !frame.includes('已有项目B - 单行描述'), 'unfocused project rows omit their descriptions')
313
+ stdin.write('\x1b[B'); await delay(15) // move focus from search to the list
314
+ stdin.write('\x1b[B'); await delay(15) // highlight the first project
315
+ const selectedFrame = lastFrame() ?? ''
316
+ ok(selectedFrame.includes('已有项目A - 第一行 ⏎ 第二行'), 'selected multi-line description stays on the main row')
317
+ ok(!selectedFrame.includes('已有项目B - 单行描述'), 'unselected project description is omitted')
318
+ ok(!frame.includes('\n 第一行') && !frame.includes('\n 单行描述'), 'project explanations do not render as an additional row')
303
319
  ok(!frame.includes('加载项目列表…'), 'completed project load does not leave a stale loading message')
304
320
 
321
+ stdin.write('\x1b[A'); await delay(15) // return to the create row
305
322
  stdin.write('\r')
306
323
  await delay(30)
307
324
  const createFrame = lastFrame() ?? ''
@@ -313,6 +330,56 @@ async function testPrepRender() {
313
330
  } finally { restoreFetch() }
314
331
  }
315
332
 
333
+ // ════════════════════════════════════════════════════════════════════════════
334
+ // TEST 5b — Project and issue pickers filter by the search field
335
+ // ════════════════════════════════════════════════════════════════════════════
336
+ async function testPrepSearch() {
337
+ console.log('\n[UI 5b] Prep picker search filtering')
338
+ const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
339
+ installMock((url) => {
340
+ if (url.includes('/api/projects') && !url.includes('/issues') && !url.includes('/skills') && !url.includes('/memories')) {
341
+ return jsonResponse([
342
+ { id: 'p1', name: '前端平台', description: '用户界面与组件' },
343
+ { id: 'p2', name: '数据管线', description: '批处理任务' },
344
+ ])
345
+ }
346
+ if (url.includes('/api/projects/p2/issues')) {
347
+ return jsonResponse([
348
+ { id: 'i1', project_id: 'p2', title: '修复导入超时', description: '处理批处理任务' },
349
+ { id: 'i2', project_id: 'p2', title: '更新监控面板', description: '前端界面' },
350
+ ])
351
+ }
352
+ if (url.includes('/sessions/model-options')) return jsonResponse([])
353
+ if (url.includes('/sessions/default-model')) return jsonResponse({ model: 'codex' })
354
+ if (url.includes('/skills') || url.includes('/memories')) return jsonResponse([])
355
+ return jsonResponse({ error: 'no mock' }, 404)
356
+ })
357
+ try {
358
+ const { lastFrame, stdin, unmount } = render(<PrepScreen client={client} onReady={() => {}} />)
359
+ await delay(120)
360
+ stdin.write('数据')
361
+ await delay(40)
362
+ let frame = lastFrame() ?? ''
363
+ ok(frame.includes('数据管线') && !frame.includes('前端平台'), 'project search keeps matching project and hides non-matches')
364
+ ok(!frame.includes('创建新项目'), 'project search hides the create row while searching')
365
+ stdin.write('\r')
366
+ await delay(160)
367
+ ok((lastFrame() ?? '').includes('选择任务(Issue)'), 'matching project opens its issue picker')
368
+
369
+ stdin.write('超时')
370
+ await delay(40)
371
+ frame = lastFrame() ?? ''
372
+ ok(frame.includes('修复导入超时') && !frame.includes('更新监控面板'), 'issue search matches title and hides other issues')
373
+ stdin.write('\r')
374
+ await delay(120)
375
+ ok((lastFrame() ?? '').includes('选择模型') || (lastFrame() ?? '').includes('加载模型列表'), 'matching issue is selected with Enter')
376
+ unmount()
377
+ // This test deliberately selects a project; remove its persisted cwd
378
+ // binding so later picker tests still start on the project screen.
379
+ try { fs.rmSync(path.join(TMP_HOME, 'dir2project.json'), { force: true }) } catch { /* ignore */ }
380
+ } finally { restoreFetch() }
381
+ }
382
+
316
383
  // ════════════════════════════════════════════════════════════════════════════
317
384
  // TEST 6 — Select viewport: a long list must not overflow the terminal
318
385
  // ════════════════════════════════════════════════════════════════════════════
@@ -482,6 +549,56 @@ async function testComposerDeleteKeys() {
482
549
  unmount()
483
550
  }
484
551
 
552
+ // ════════════════════════════════════════════════════════════════════════════
553
+ // TEST 8d — Home/End and Ctrl+Left/Right cursor movement
554
+ // ════════════════════════════════════════════════════════════════════════════
555
+ async function testCursorNavigationKeys() {
556
+ console.log('\n[UI 8d] Home/End + Ctrl-arrow cursor navigation')
557
+
558
+ let textInputSubmitted = ''
559
+ function TextHarness() {
560
+ const [v, setV] = React.useState('alpha beta')
561
+ return <TextInput value={v} onChange={setV} focused onSubmit={() => { textInputSubmitted = v }} />
562
+ }
563
+ const textInput = render(<TextHarness />)
564
+ await delay(20)
565
+ textInput.stdin.write('\x1b[H'); await delay(15) // Home
566
+ textInput.stdin.write('^'); await delay(15)
567
+ textInput.stdin.write('\x1b[F'); await delay(15) // End
568
+ textInput.stdin.write('$'); await delay(15)
569
+ textInput.stdin.write('\x1b[1;5D'); await delay(15) // Ctrl+Left
570
+ textInput.stdin.write('|'); await delay(15)
571
+ textInput.stdin.write('\x1b[1;5C'); await delay(15) // Ctrl+Right
572
+ textInput.stdin.write('!'); await delay(15)
573
+ textInput.stdin.write('\r'); await delay(20)
574
+ textInput.unmount()
575
+ ok(textInputSubmitted === '^alpha |beta$!', `TextInput cursor keys edit at expected boundaries (got ${JSON.stringify(textInputSubmitted)})`)
576
+
577
+ const composerSubmitted: string[] = []
578
+ const composer = render(
579
+ <Composer
580
+ onSubmit={v => composerSubmitted.push(v)}
581
+ onStop={() => {}}
582
+ onQuit={() => {}}
583
+ typing={false}
584
+ commands={[]}
585
+ />,
586
+ )
587
+ await delay(20)
588
+ composer.stdin.write('alpha beta'); await delay(20)
589
+ composer.stdin.write('\x1b[H'); await delay(15)
590
+ composer.stdin.write('^'); await delay(15)
591
+ composer.stdin.write('\x1b[F'); await delay(15)
592
+ composer.stdin.write('$'); await delay(15)
593
+ composer.stdin.write('\x1b[1;5D'); await delay(15)
594
+ composer.stdin.write('|'); await delay(15)
595
+ composer.stdin.write('\x1b[1;5C'); await delay(15)
596
+ composer.stdin.write('!'); await delay(15)
597
+ composer.stdin.write('\r'); await delay(30)
598
+ composer.unmount()
599
+ ok(composerSubmitted[0] === '^alpha |beta$!', `Composer cursor keys edit at expected boundaries (got ${JSON.stringify(composerSubmitted[0])})`)
600
+ }
601
+
485
602
  // ════════════════════════════════════════════════════════════════════════════
486
603
  // TEST 9 — Codex-style composer keeps multiline pastes intact and grows/shrinks
487
604
  // ════════════════════════════════════════════════════════════════════════════
@@ -500,7 +617,7 @@ async function testComposerMultilinePaste() {
500
617
  await delay(20)
501
618
  const initial = lastFrame() ?? ''
502
619
  ok(initial.includes('╭') && initial.includes('╰'), 'composer has a visible bordered input boundary')
503
- ok(initial.includes('Enter 发送') && initial.includes('Ctrl+J 换行'), 'composer shows Codex-style submit/newline hints')
620
+ ok(initial.includes('Enter 发送') && initial.includes('Shift+Enter / Alt+Enter / Ctrl+J 换行'), 'composer shows submit/newline hints')
504
621
 
505
622
  stdin.write('\x1b[200~第一行\r\n第二行\r第三行\x1b[201~')
506
623
  await delay(20)
@@ -747,7 +864,7 @@ async function testChatSseReconnects() {
747
864
  })
748
865
  try {
749
866
  const { stdin, lastFrame, unmount } = render(
750
- <ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
867
+ <ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
751
868
  )
752
869
  await delay(40)
753
870
  // Ink's test stdin treats one chunk as one keypress. Send text and Enter as
@@ -819,7 +936,7 @@ async function testIdleCompletedSessionReopensSseOnSend() {
819
936
  })
820
937
  try {
821
938
  const { stdin, lastFrame, unmount } = render(
822
- <ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
939
+ <ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
823
940
  )
824
941
  await delay(180)
825
942
  ok(sseCall === 1, 'completed idle session did not reconnect by itself')
@@ -859,7 +976,7 @@ async function testSendRetries502() {
859
976
  })
860
977
  try {
861
978
  const { stdin, lastFrame, unmount } = render(
862
- <ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
979
+ <ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
863
980
  )
864
981
  await delay(40)
865
982
  stdin.write('hi'); await delay(30); stdin.write('\r')
@@ -878,11 +995,13 @@ async function main() {
878
995
  testMarkdownCodeRendering()
879
996
  testFirstUserEntryDedupe()
880
997
  await testPrepRender()
998
+ await testPrepSearch()
881
999
  await testSelectViewport()
882
1000
  await testProjectPickerEscQuit()
883
1001
  await testTextInputBackspace()
884
1002
  await testTextInputDeleteKeys()
885
1003
  await testComposerDeleteKeys()
1004
+ await testCursorNavigationKeys()
886
1005
  await testComposerMultilinePaste()
887
1006
  testWorkingShimmer()
888
1007
  testReasoningViews()