@mobius-os/mobius 0.3.31 → 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/README.md CHANGED
@@ -130,6 +130,13 @@ npm test # all three
130
130
 
131
131
  ## Build an installable package
132
132
 
133
+ The TUI release version has one source of truth: `mobius/tui/package.json`.
134
+ The welcome screen, npm package metadata, artifact filename, and download
135
+ manifest all read that value. `package-lock.json` mirrors it as generated npm
136
+ metadata; do not edit it as a separate release setting. The AIMUX bundle
137
+ version (`BUNDLE_VER`) is an independent Python runtime cache version and is
138
+ not the TUI version.
139
+
133
140
  From the repository root:
134
141
 
135
142
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.31",
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": {
@@ -19,6 +19,7 @@
19
19
  "test:reconnect": "tsx tests/reconnect.test.tsx",
20
20
  "test:screen": "FORCE_COLOR=1 tsx tests/screen.test.tsx",
21
21
  "test:scroll": "tsx tests/scroll.test.tsx",
22
+ "test:viewport": "tsx tests/viewport.test.ts",
22
23
  "test:selection": "FORCE_COLOR=1 tsx tests/selection.test.tsx",
23
24
  "test": "npm run typecheck && npm run test:ui && npm run test:integration"
24
25
  },
@@ -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
 
@@ -13,18 +13,22 @@ import { useChat } from '../hooks/useChat.js'
13
13
  import { MobiusClient } from '../api.js'
14
14
  import { viewsForEntry, dedupeUserEntries, isAssistantOutput } from '../lib/entry-view.js'
15
15
  import {
16
- displayWidth, compareSel, entryScreenLines, entryScreenRows,
17
- buildTranscriptModel, computeTranscriptGeometry, screenToSelPoint,
16
+ displayWidth, compareSel, entryScreenRows, screenToSelPoint,
18
17
  buildSelectionMap, buildSelectionText, osc52,
19
18
  type TranscriptModel, type TranscriptGeometry, type SelPoint, type ScreenRow,
20
19
  } from '../lib/screen-text.js'
20
+ import {
21
+ createRowAccess, moveAnchorByRows, sliceViewport, tailAnchor,
22
+ type RowAnchor,
23
+ } from '../lib/transcript-viewport.js'
21
24
  import type { ReadyState } from './PrepScreen.js'
22
25
  import type { AnyEntry } from '../types.js'
23
26
  import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
24
27
  import type { AimuxStatus } from '../aimux.js'
25
28
  import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
26
29
  import { isEscapeKeypress, isMouseInput, useMouseEvents, useStableInput } from './primitives.js'
27
- 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'
28
32
 
29
33
  interface ChatProps {
30
34
  client: MobiusClient
@@ -34,6 +38,7 @@ interface ChatProps {
34
38
  onClear: () => void
35
39
  onResume: () => void
36
40
  onQuit: () => void
41
+ onLogout: () => void
37
42
  onReconfigure: (result: ConfigResult) => void
38
43
  onConfigCancel: (sessionId: string | null) => void
39
44
  aimuxStatus?: AimuxStatus
@@ -45,8 +50,7 @@ interface TerminalSize {
45
50
  isTty: boolean
46
51
  }
47
52
 
48
- import { createRequire } from 'node:module'
49
- const VERSION = createRequire(import.meta.url)('../../package.json').version
53
+ import { TUI_VERSION } from '../version.js'
50
54
  const DEFAULT_COMPOSER_ROWS = 5
51
55
  const STATUS_ROWS = 3
52
56
 
@@ -55,14 +59,17 @@ const SLASH_COMMANDS = [
55
59
  { cmd: '/resume', desc: '恢复一个历史会话' },
56
60
  { cmd: '/model', desc: '更换模型并开启新会话(保留当前任务)' },
57
61
  { cmd: '/config', desc: '重新选择项目、任务和模型' },
62
+ { cmd: '/logout', desc: '断开当前连接并返回登录界面' },
58
63
  { cmd: '/help', desc: '显示帮助' },
59
64
  { cmd: '/quit', desc: '退出 TUI' },
60
65
  ]
61
66
 
62
- 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) {
63
68
  const chat = useChat({ client, ready, resumeSessionId })
64
69
  const [showHelp, setShowHelp] = useState(false)
65
- const [scrollBack, setScrollBack] = useState(0)
70
+ // null means "follow the tail". A concrete anchor identifies the exact row
71
+ // at the top of the viewport while the user browses history.
72
+ const [rowAnchor, setRowAnchor] = useState<RowAnchor | null>(null)
66
73
  const [composerRows, setComposerRows] = useState(DEFAULT_COMPOSER_ROWS)
67
74
  const [modelLabel, setModelLabel] = useState<string | null>(null)
68
75
  const [configOpen, setConfigOpen] = useState(false)
@@ -87,10 +94,11 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
87
94
  case '/help': setShowHelp(s => !s); return true
88
95
  case '/model': setConfigOpen(true); return true
89
96
  case '/config': setReconfigOpen(true); return true
97
+ case '/logout': onLogout(); return true
90
98
  case '/quit': case '/exit': onQuit(); return true
91
99
  default: return false
92
100
  }
93
- }, [onClear, onResume, onQuit])
101
+ }, [onClear, onResume, onQuit, onLogout])
94
102
 
95
103
  const onSubmit = useCallback((text: string) => {
96
104
  const t = text.trim()
@@ -100,7 +108,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
100
108
  return
101
109
  }
102
110
  setShowHelp(false)
103
- setScrollBack(0)
111
+ setRowAnchor(null)
104
112
  void chat.send(t)
105
113
  }, [chat, runSlash])
106
114
 
@@ -116,39 +124,66 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
116
124
  // (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
117
125
  // 避免在累积视图里把同一条提问显示多次.
118
126
  const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
119
- // Cache: entry index rendered line count. Cleared when entries or columns change.
120
- const entryLines = useRef<Map<number, number>>(new Map())
121
- useEffect(() => { entryLines.current.clear() }, [dedupedEntries, terminal.columns])
122
- const getEntryLines = useCallback((i: number) => {
123
- const c = entryLines.current.get(i)
124
- if (c !== undefined) return c
125
- const n = entryScreenLines(viewsForEntry(dedupedEntries[i]), terminal.columns).length || 1
126
- entryLines.current.set(i, n)
127
- return n
128
- }, [dedupedEntries, terminal.columns])
127
+ const pendingEntry = useMemo<AnyEntry | null>(() => chat.pendingUser === null ? null : ({
128
+ type: 'user',
129
+ __id: '__pending-user__',
130
+ message: { role: 'user', content: chat.pendingUser },
131
+ }), [chat.pendingUser])
132
+ const transcriptEntries = useMemo(
133
+ () => pendingEntry ? [...dedupedEntries, pendingEntry] : dedupedEntries,
134
+ [dedupedEntries, pendingEntry],
135
+ )
136
+
137
+ // Markdown parsing and wrapping are paid once per entry/terminal width. Keep
138
+ // the two most recent widths so resize-back does not immediately reparse the
139
+ // whole visible history, while bounding cache growth during repeated resizes.
140
+ const rowCache = useRef<WeakMap<AnyEntry, Map<number, ScreenRow[]>>>(new WeakMap())
141
+ const rowsForEntry = useCallback((entry: AnyEntry): readonly ScreenRow[] => {
142
+ let widths = rowCache.current.get(entry)
143
+ if (!widths) {
144
+ widths = new Map()
145
+ rowCache.current.set(entry, widths)
146
+ }
147
+ const cached = widths.get(terminal.columns)
148
+ if (cached) return cached
149
+ const rows = entryScreenRows(viewsForEntry(entry), terminal.columns)
150
+ if (widths.size >= 2) widths.delete(widths.keys().next().value!)
151
+ widths.set(terminal.columns, rows)
152
+ return rows
153
+ }, [terminal.columns])
154
+ const rowAccess = useMemo(() => createRowAccess(
155
+ transcriptEntries,
156
+ // UUID is stable across SSE history replay; __id is only the local fallback
157
+ // for entries that do not carry a backend identity (notably the optimistic
158
+ // pending user row).
159
+ (entry, index) => String(entry.uuid ?? entry.__id ?? `entry-${index}`),
160
+ (entry) => rowsForEntry(entry),
161
+ ), [transcriptEntries, rowsForEntry])
129
162
  const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
130
163
  const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
131
164
  const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
132
- const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 3)
133
- const fitted = useMemo(
134
- () => fitTranscript(dedupedEntries, transcriptRows, terminal.columns, scrollBack),
135
- [dedupedEntries, transcriptRows, terminal.columns, scrollBack],
165
+ // Conversation chrome is exactly two rows: compact header + navigation.
166
+ const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 2)
167
+ const tail = useMemo(() => tailAnchor(rowAccess, transcriptRows), [rowAccess, transcriptRows])
168
+ const effectiveAnchor = rowAnchor ?? tail
169
+ const viewport = useMemo(
170
+ () => sliceViewport(rowAccess, effectiveAnchor, transcriptRows),
171
+ [rowAccess, effectiveAnchor, transcriptRows],
136
172
  )
137
- const showWelcome = dedupedEntries.length === 0 && chat.pendingUser === null && scrollBack === 0
138
-
139
- // Keep a history page pinned while new events stream in. At the latest page,
140
- // new output continues to auto-follow as usual.
141
- const prevLenRef = useRef(dedupedEntries.length)
142
- useEffect(() => {
143
- const previous = prevLenRef.current
144
- const current = dedupedEntries.length
145
- prevLenRef.current = current
146
- if (current > previous && scrollBack > 0) {
147
- setScrollBack(value => value + current - previous)
148
- } else if (scrollBack > current) {
149
- setScrollBack(current)
150
- }
151
- }, [dedupedEntries.length, scrollBack])
173
+ const showWelcome = transcriptEntries.length === 0
174
+ const pageRows = Math.max(1, transcriptRows - 1)
175
+
176
+ const scrollRows = useCallback((deltaRows: number) => {
177
+ if (deltaRows === 0) return
178
+ selState.current = null
179
+ setSel(null)
180
+ setRowAnchor(previous => {
181
+ const start = previous ?? tailAnchor(rowAccess, transcriptRows)
182
+ const next = moveAnchorByRows(rowAccess, start, deltaRows)
183
+ if (deltaRows > 0 && !sliceViewport(rowAccess, next, transcriptRows).hasNewer) return null
184
+ return next
185
+ })
186
+ }, [rowAccess, transcriptRows])
152
187
 
153
188
  // Show the model's friendly label (e.g. "GPT-5.6-Sol") in the header/status
154
189
  // instead of its opaque key (e.g. "codex:mobiusdefaultaabb").
@@ -170,20 +205,18 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
170
205
  // the keypress). configOpen/reconfigOpen/sessionId are read from handlerRef
171
206
  // (see above) because Ink keeps the originally-registered callback and would
172
207
  // otherwise see a stale closure.
173
- if (handlerRef.current.configOpen || handlerRef.current.reconfigOpen) {
208
+ if (handlerRef.current.configOpen) {
174
209
  if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
175
210
  return
176
211
  }
177
- const step = Math.max(1, fitted.entries.length)
178
- if (key.pageUp) setScrollBack(value => Math.min(dedupedEntries.length, value + step))
179
- else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
212
+ if (handlerRef.current.reconfigOpen) return // ReconfigFlow owns hierarchical Esc navigation.
213
+ if (key.pageUp) scrollRows(-pageRows)
214
+ else if (key.pageDown) scrollRows(pageRows)
180
215
  }, { interactive: false })
181
216
 
182
- const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
183
- ? fitted.hiddenOlder > 0
184
- ? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp 翻页 · 拖动选中文本`
185
- : '已到最早记录 · 滚轮/PageDown 翻页 · 拖动选中文本'
186
- : null
217
+ const navigationHint = viewport.hasOlder
218
+ ? `${viewport.hasNewer ? '↑ 较早内容 · ↓ 较新内容' : '↑ 还有较早内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
219
+ : `${viewport.hasNewer ? '已到最早 · ↓ 还有较新内容' : '全部内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} · 拖动选中文本`
187
220
 
188
221
  // Mouse: wheel pages through history in small fixed steps, and a left-button
189
222
  // drag selects transcript text (tmux-style: the app owns the mouse, draws its
@@ -195,23 +228,16 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
195
228
  const [sel, setSel] = useState<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
196
229
  const [copyNotice, setCopyNotice] = useState<string | null>(null)
197
230
 
198
- // Geometry + text model must mirror the rendered transcript so a screen
199
- // (row, col) maps to the right entry/line/char. Recompute with the fitted view.
200
- const tipShown = dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
201
- const geometry: TranscriptGeometry = useMemo(() => computeTranscriptGeometry({
202
- viewportRows,
203
- composerRows,
204
- statusRows: STATUS_ROWS,
205
- activityRows,
206
- helpRows,
207
- showWelcome,
208
- welcomeRows: 10,
209
- olderHintShown: olderHint !== null,
210
- tipShown,
211
- }), [viewportRows, composerRows, activityRows, helpRows, showWelcome, olderHint, tipShown])
231
+ // Selection uses the exact virtual rows mounted below. There is no separate
232
+ // fitting/geometry pass, so hit-testing, rendering and clipboard extraction
233
+ // cannot disagree about which rows are on screen.
234
+ const geometry: TranscriptGeometry = useMemo(
235
+ () => ({ boxTop: 2, boxH: transcriptRows }),
236
+ [transcriptRows],
237
+ )
212
238
  const transcriptModel: TranscriptModel = useMemo(
213
- () => buildTranscriptModel(fitted.entries, terminal.columns),
214
- [fitted.entries, terminal.columns],
239
+ () => ({ entries: viewport.rows.map(item => [item.row.plain]), totalRows: viewport.rows.length }),
240
+ [viewport.rows],
215
241
  )
216
242
  const selMap = useMemo(
217
243
  () => (sel?.active ? buildSelectionMap(transcriptModel, sel.anchor, sel.end) : null),
@@ -227,25 +253,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
227
253
 
228
254
  useMouseEvents({
229
255
  onWheel: (delta) => {
230
- if (delta === 0) return
231
- const targetLines = 2
232
- let lines = 0
233
- let next = scrollBack
234
- const n = dedupedEntries.length
235
- if (delta > 0) {
236
- // scroll up (older): hide more entries from the tail
237
- for (let i = n - 1 - next; i >= 0 && lines < targetLines; i--) {
238
- lines += getEntryLines(i)
239
- next++
240
- }
241
- } else {
242
- // scroll down (newer): unhide entries from the tail
243
- for (let i = n - next; i < n && lines < targetLines; i++) {
244
- lines += getEntryLines(i)
245
- next--
246
- }
247
- }
248
- setScrollBack(Math.min(n, Math.max(0, next)))
256
+ scrollRows(delta * -3)
249
257
  },
250
258
  onPress: (row, col) => {
251
259
  const p = screenToSelPoint(row, col, transcriptModel, geometry)
@@ -307,6 +315,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
307
315
  <ReconfigFlow
308
316
  client={client}
309
317
  onDone={(result) => onReconfigure(result)}
318
+ onCancel={() => onConfigCancel(chat.sessionId)}
310
319
  />
311
320
  </Box>
312
321
  )
@@ -325,34 +334,25 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
325
334
  ? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
326
335
  : <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
327
336
 
328
- {/* Older-records hint is pinned OUTSIDE the flex-end scroll box so it is
329
- always the first line of the transcript, spanning the full width,
330
- instead of floating mid-screen when the transcript has spare rows. */}
331
- {olderHint !== null
332
- ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {olderHint}</Text></Box>
337
+ {!showWelcome
338
+ ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {navigationHint}</Text></Box>
333
339
  : null}
334
340
 
335
- <Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome || fitted.hiddenOlder > 0 ? 'flex-start' : 'flex-end'} overflowY="hidden">
336
- {fitted.peekRows.length > 0
337
- ? <Box width="100%" flexShrink={0} flexDirection="column">
338
- {fitted.peekRows.map((row, index) => (
339
- <ScreenText
340
- key={`peek-${index}`}
341
- row={row}
342
- text={`${index === 0 ? ' ⋯ ' : ' '}${row.styled}`}
343
- />
344
- ))}
345
- </Box>
346
- : null}
347
- {fitted.entries.map((entry, index) => {
348
- const entrySel = selMap?.get(index)
349
- const key = entry.__id ?? `entry-${fitted.startIndex + index}`
350
- return <EntryScreen key={key} entry={entry} columns={terminal.columns} sel={entrySel} />
351
- })}
352
- {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
353
- {fitted.hiddenRecent > 0
354
- ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> ↓ 滚轮/PageDown 翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
355
- : null}
341
+ <Box
342
+ height={showWelcome ? undefined : transcriptRows}
343
+ flexGrow={showWelcome ? 1 : 0}
344
+ flexShrink={showWelcome ? 1 : 0}
345
+ flexDirection="column"
346
+ justifyContent="flex-end"
347
+ overflowY="hidden"
348
+ >
349
+ {!showWelcome ? viewport.rows.map((item, index) => {
350
+ const range = selMap?.get(index)?.get(0)
351
+ const text = range && range.start < range.end
352
+ ? highlightScreenRow(item.row.styled, range.start, range.end)
353
+ : item.row.styled
354
+ return <ScreenText key={`${item.entryId}:${item.rowIndex}`} row={item.row} text={text || ' '} />
355
+ }) : null}
356
356
  </Box>
357
357
 
358
358
  {dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
@@ -417,7 +417,7 @@ function WelcomeCard({ ready, columns, resumed, modelDisplay }: { ready: ReadySt
417
417
  <Text>
418
418
  <Text dimColor>{'>_ '}</Text>
419
419
  <Text bold>Mobius</Text>
420
- <Text dimColor> (v{VERSION})</Text>
420
+ <Text dimColor> (v{TUI_VERSION})</Text>
421
421
  </Text>
422
422
  <Text> </Text>
423
423
  <MetaRow label="model:" value={modelDisplay} hint="/help 查看命令" labelWidth={labelWidth} />
@@ -452,29 +452,6 @@ function CompactHeader({ ready, sessionId, columns }: { ready: ReadyState; sessi
452
452
  )
453
453
  }
454
454
 
455
- // Normal and selected transcript rows use the exact same component tree. Mouse
456
- // motion now changes only ANSI background bytes inside a row; it never swaps a
457
- // rich Markdown entry for a structurally different plain-text entry, which used
458
- // to make long/styled messages jump as the selection crossed entry boundaries.
459
- function EntryScreen({ entry, columns, sel }: {
460
- entry: AnyEntry
461
- columns: number
462
- sel?: Map<number, { start: number; end: number }>
463
- }) {
464
- const rows = entryScreenRows(viewsForEntry(entry), columns)
465
- return (
466
- <Box flexDirection="column">
467
- {rows.map((row, index) => {
468
- const range = sel?.get(index)
469
- const text = range && range.start < range.end
470
- ? highlightScreenRow(row.styled, range.start, range.end)
471
- : row.styled
472
- return <ScreenText key={index} row={row} text={text || ' '} />
473
- })}
474
- </Box>
475
- )
476
- }
477
-
478
455
  function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
479
456
  const tone = row.tone
480
457
  const color = tone === 'tool' ? 'cyan'
@@ -529,13 +506,6 @@ function highlightScreenRow(styled: string, start: number, end: number): string
529
506
  return out
530
507
  }
531
508
 
532
- function UserLine({ text }: { text: string }) {
533
- const lines = text.split('\n')
534
- if (lines[0] !== undefined) lines[0] = `› ${lines[0]}`
535
- for (let i = 1; i < lines.length; i++) lines[i] = ` ${lines[i]}`
536
- return <Box marginTop={1}><Text bold>{lines.join('\n')}</Text></Box>
537
- }
538
-
539
509
  function WorkingIndicator({ firstQuery }: { firstQuery: boolean }) {
540
510
  const startedAt = useRef(Date.now())
541
511
  const [animationFrame, setAnimationFrame] = useState(0)
@@ -625,6 +595,13 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
625
595
  const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
626
596
  edit(text, nextCursor)
627
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
+ })
628
605
 
629
606
  const filtered = useMemo(() => {
630
607
  const match = /^(\w*)$/.exec(value.slice(1))
@@ -826,6 +803,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
826
803
  edit(text, nextCursor)
827
804
  return
828
805
  }
806
+ if (key.ctrl && (key.leftArrow || key.rightArrow)) return
829
807
  if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
830
808
  if (key.rightArrow) { moveCursor(nextCursorBoundary(current, at)); return }
831
809
 
@@ -921,7 +899,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
921
899
  })}
922
900
  </Box>
923
901
  <Box justifyContent="space-between">
924
- <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>
925
903
  <Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
926
904
  </Box>
927
905
  </Box>
@@ -1104,60 +1082,3 @@ function clickableUrl(url: string, maxLen?: number): string {
1104
1082
 
1105
1083
  // displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
1106
1084
  // here to size the AIMUX status block so the web URL truncates exactly.
1107
-
1108
- export function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
1109
- entries: AnyEntry[]
1110
- /** Tail rows of the next older entry, used to fill spare space above the viewport. */
1111
- peekRows: ScreenRow[]
1112
- hiddenOlder: number
1113
- hiddenRecent: number
1114
- startIndex: number
1115
- } {
1116
- const tail = Math.max(0, entries.length - scrollBack)
1117
- const available = tail === 0 ? [] : entries.slice(0, tail)
1118
- const renderedRows = available.map((entry) => entryScreenRows(viewsForEntry(entry), columns))
1119
- const fit = (budget: number) => {
1120
- let rows = 0
1121
- let first = available.length
1122
- for (let index = available.length - 1; index >= 0; index--) {
1123
- const nextRows = renderedRows[index].length
1124
- if (first < available.length && rows + nextRows > budget) break
1125
- rows += nextRows
1126
- first = index
1127
- }
1128
- return { first, rows }
1129
- }
1130
-
1131
- const base = fit(rowBudget)
1132
- let fitted = base
1133
- let first = fitted.first
1134
- let peekRows: ScreenRow[] = []
1135
- // When older history exists, guarantee at least one row for the tail of the
1136
- // next older message. If complete entries exactly consume the budget, refit
1137
- // them with one fewer row; only the oldest complete entry can drop out, while
1138
- // the latest content remains visible. A single oversized entry keeps its
1139
- // original rendering because it cannot safely donate a row.
1140
- if (first > 0 && fitted.rows <= rowBudget) {
1141
- if (fitted.rows === rowBudget && rowBudget > 1) {
1142
- const reduced = fit(rowBudget - 1)
1143
- if (reduced.first > 0 && reduced.rows <= rowBudget - 1) {
1144
- fitted = reduced
1145
- first = reduced.first
1146
- }
1147
- }
1148
- const olderRows = renderedRows[first - 1].slice()
1149
- while (olderRows.length > 0 && !olderRows[0].plain.trim()) olderRows.shift()
1150
- while (olderRows.length > 0 && !olderRows[olderRows.length - 1].plain.trim()) olderRows.pop()
1151
- const spare = rowBudget - fitted.rows
1152
- if (spare > 0 && olderRows.length > 0) {
1153
- peekRows = olderRows.slice(-spare)
1154
- }
1155
- }
1156
- return {
1157
- entries: available.slice(first),
1158
- peekRows,
1159
- hiddenOlder: first,
1160
- hiddenRecent: entries.length - tail,
1161
- startIndex: first,
1162
- }
1163
- }