@mobius-os/mobius 0.3.9 → 0.3.14

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.9",
3
+ "version": "0.3.14",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
package/src/aimux.ts CHANGED
@@ -107,6 +107,13 @@ function appendAimuxLog(write: { queue: Promise<void> }, text: string): void {
107
107
  .catch(() => {})
108
108
  }
109
109
 
110
+ // 安装阶段(download/extract/verify)独享的日志队列 —— 与 supervisor 的分离,避免互相阻塞。
111
+ // 关键: 原先安装阶段一行都不写 aimux.log,"卡在解压内置运行时"时日志完全空白 = 黑盒。
112
+ // 现在每个子步骤(连接/首字节/字节增量/解压文件数/import 校验/退出码/耗时)都落盘带时间戳,
113
+ // 下次卡住直接 tail ~/.mobius/aimux.log 就知道卡在第几秒、哪个环节、下了多少 MB。
114
+ const installLogQueue = { queue: Promise.resolve() as Promise<void> }
115
+ const logInstall = (text: string): void => appendAimuxLog(installLogQueue, text)
116
+
110
117
  /** 当前平台对应的内置运行时包名;mac-arm64 走 mac-x64(Rosetta 2)。 */
111
118
  export function bundleArch(): string | null {
112
119
  const { platform, arch } = process
@@ -139,55 +146,116 @@ async function downloadBundle(arch: string, onProgress?: (p: InstallProgress) =>
139
146
  const url = bundleUrl(arch)
140
147
  const zipPath = path.join(mobiusHome(), `python-bundle-v${BUNDLE_VER}.zip.tmp`)
141
148
  await fs.mkdir(path.dirname(zipPath), { recursive: true }) // 首次安装 ~/.mobius 可能尚未创建
149
+ const startedAt = Date.now()
150
+ logInstall(`\n===== bundle download start ${new Date().toISOString()} arch=${arch} =====\n url=${url}\n`)
151
+ // fetch 无内置超时:受限网络(训练 pod 出网被掐 / 被透明代理劫持成慢速 chunked)下会永久挂起 → spinner 永转。
152
+ // 用一个可重置的 AbortController:连接/首字节给 CONNECT_MS,之后每收到一块重置为 STALL_MS,停滞即 abort 并给出可读原因。
153
+ const CONNECT_MS = 45_000, STALL_MS = 30_000
154
+ const controller = new AbortController()
155
+ let timer: ReturnType<typeof setTimeout> | null = null
156
+ let stallReason = ''
157
+ const arm = (ms: number, reason: string) => { if (timer) clearTimeout(timer); stallReason = reason; timer = setTimeout(() => controller.abort(), ms) }
158
+ arm(CONNECT_MS, `连接/首字节超时(${CONNECT_MS / 1000}s 未响应,可能出网被掐)`)
142
159
  let res: Response
143
- try { res = await fetch(url) } catch (e: any) { return { ok: false, error: `下载失败: ${e?.message ?? e}` } }
144
- if (!res.ok) return { ok: false, error: `下载失败: HTTP ${res.status} (${url})` }
160
+ try { res = await fetch(url, { signal: controller.signal }) }
161
+ catch (e: any) {
162
+ if (timer) clearTimeout(timer)
163
+ const msg = e?.name === 'AbortError' ? `下载失败: ${stallReason}` : `下载失败: ${e?.message ?? e}`
164
+ logInstall(` fetch error: ${e?.name} ${e?.message ?? e} elapsed=${Date.now() - startedAt}ms\n`)
165
+ return { ok: false, error: msg }
166
+ }
167
+ if (!res.ok) { if (timer) clearTimeout(timer); logInstall(` HTTP ${res.status} ${res.statusText}\n`); return { ok: false, error: `下载失败: HTTP ${res.status} (${url})` } }
145
168
  const total = Number(res.headers.get('content-length') || 0)
169
+ logInstall(` 200 OK content-length=${total || 'unknown (chunked)'}\n`)
146
170
  const ws = createWriteStream(zipPath)
147
171
  let got = 0, last = 0
148
172
  try {
149
173
  const stream = Readable.fromWeb(res.body as any)
150
174
  for await (const chunk of stream) {
175
+ arm(STALL_MS, `下载停滞超时(已下载 ${(got / 1048576).toFixed(0)}MB,${STALL_MS / 1000}s 无新增数据)`)
151
176
  ws.write(chunk as Buffer)
152
177
  got += (chunk as Buffer).length
153
- if (total && got - last > total * 0.03) { last = got; onProgress?.({ phase: 'install', detail: `下载内置运行时 ${Math.round((got / total) * 100)}%` }) }
178
+ // 始终反馈进度:有 content-length 用百分比,否则按 3MB 增量报 MB(chunked 传输无 total 时也能动)。
179
+ if (total) { if (got - last > total * 0.03) { last = got; onProgress?.({ phase: 'install', detail: `下载内置运行时 ${Math.round((got / total) * 100)}%` }) } }
180
+ else if (got - last > 3 * 1048576) { last = got; onProgress?.({ phase: 'install', detail: `下载内置运行时 ${(got / 1048576).toFixed(0)}MB` }) }
154
181
  }
155
- if (!total && got) onProgress?.({ phase: 'install', detail: `下载内置运行时 ${(got / 1048576).toFixed(0)}MB` })
182
+ onProgress?.({ phase: 'install', detail: total ? `下载内置运行时 100%` : `下载内置运行时 ${(got / 1048576).toFixed(0)}MB` })
156
183
  await new Promise<void>((resolve, reject) => { ws.end(() => resolve()); ws.on('error', reject) })
184
+ logInstall(` done bytes=${got} (${(got / 1048576).toFixed(1)}MB) elapsed=${Date.now() - startedAt}ms avg=${Math.round(got / 1024 / Math.max(1, (Date.now() - startedAt) / 1000))}KB/s\n`)
157
185
  } catch (e: any) {
158
186
  try { ws.destroy() } catch {}
159
187
  try { await fs.unlink(zipPath) } catch {}
160
- return { ok: false, error: `下载失败: ${e?.message ?? e}` }
161
- }
188
+ const msg = e?.name === 'AbortError' ? `下载失败: ${stallReason}` : `下载失败: ${e?.message ?? e}`
189
+ logInstall(` stream error: ${e?.name} ${e?.message ?? e} got=${got} (${(got / 1048576).toFixed(1)}MB) elapsed=${Date.now() - startedAt}ms\n`)
190
+ return { ok: false, error: msg }
191
+ } finally { if (timer) clearTimeout(timer) }
162
192
  return { ok: true, zipPath }
163
193
  }
164
194
 
165
- async function extractBundle(zipPath: string): Promise<{ ok: boolean; error?: string }> {
195
+ async function extractBundle(zipPath: string, onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string }> {
166
196
  const staging = path.join(mobiusHome(), 'python-bundle.new')
167
197
  const finalDir = bundleDir()
168
198
  const stagingPython = WIN ? path.join(staging, 'python', 'python.exe') : path.join(staging, 'python', 'bin', 'python3')
169
199
  await fs.rm(staging, { recursive: true, force: true }).catch(() => {})
170
200
  await fs.mkdir(staging, { recursive: true })
171
- try { await extract(zipPath, { dir: staging, defaultDirMode: 0o755, defaultFileMode: 0o644 }) }
172
- catch (e: any) { await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); return { ok: false, error: `解压失败: ${e?.message ?? e}` } }
201
+ logInstall(` extract start ${staging}\n`)
202
+ let entryCount = 0
203
+ const startedAt = Date.now()
204
+ // 解压几千个小文件在慢盘(网络 FS / CPFS)上可能耗时数十秒;用 onEntry 计数周期性反馈进度,
205
+ // 避免"解压内置运行时…"文案在漫长解压期间一动不动 = 看起来像死机。
206
+ try {
207
+ await extract(zipPath, {
208
+ dir: staging, defaultDirMode: 0o755, defaultFileMode: 0o644,
209
+ onEntry: () => { entryCount += 1; if (entryCount % 300 === 0) onProgress?.({ phase: 'install', detail: `解压内置运行时… ${entryCount} 个文件` }) },
210
+ })
211
+ } catch (e: any) { await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); logInstall(` extract error: ${e?.message ?? e} entries=${entryCount}\n`); return { ok: false, error: `解压失败: ${e?.message ?? e}` } }
212
+ logInstall(` extract done entries=${entryCount} elapsed=${Date.now() - startedAt}ms\n`)
173
213
  if (!WIN) try { await fs.chmod(stagingPython, 0o755) } catch {} // 保险:确保可执行位(extract-zip 通常已还原)
174
214
  await fs.rm(finalDir, { recursive: true, force: true }).catch(() => {})
175
215
  await fs.rename(staging, finalDir)
176
216
  return { ok: true }
177
217
  }
178
218
 
219
+ /** 解压后校验内置 python 能 import aimux。用 spawn(非阻塞 spawnSync)避免冻结 Ink 渲染;
220
+ * 60s 上限强杀(首次 import 在慢盘上可能慢,但不会无限);stderr(traceback) 落日志,让"解压完仍卡"可诊断。 */
221
+ async function verifyBundle(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string }> {
222
+ onProgress?.({ phase: 'install', detail: '校验内置运行时(首次 import aimux,可能耗时)…' })
223
+ const py = bundlePython()
224
+ logInstall(` verify start: ${py} -c "import aimux…" (expect v${BUNDLE_AIMUX_VERSION})\n`)
225
+ const startedAt = Date.now()
226
+ return new Promise(resolve => {
227
+ let child: ChildProcess
228
+ try { child = spawn(py, ['-c', bundleHealthCheckCode()], { windowsHide: true }) }
229
+ catch (e: any) { resolve({ ok: false, error: `校验失败: ${e?.message ?? e}` }); return }
230
+ let stderr = ''
231
+ const timer = setTimeout(() => { try { child.kill('SIGKILL') } catch {}; resolve({ ok: false, error: `校验超时(60s 未完成 import aimux,疑似慢盘)· 日志: ${aimuxLogPath()}` }) }, 60_000)
232
+ child.stdout?.on('data', b => logInstall(` verify stdout: ${b.toString('utf8').slice(-200)}`))
233
+ child.stderr?.on('data', b => { stderr += b.toString('utf8') })
234
+ child.on('error', e => { clearTimeout(timer); resolve({ ok: false, error: `校验失败: ${e.message}` }) })
235
+ child.on('close', code => {
236
+ clearTimeout(timer)
237
+ logInstall(` verify exit code=${code} elapsed=${Date.now() - startedAt}ms stderr=${stderr.slice(-300) || '(empty)'}\n`)
238
+ if (code === 0) resolve({ ok: true })
239
+ else resolve({ ok: false, error: `内置运行时无法 import aimux (code=${code}) · 日志: ${aimuxLogPath()}` })
240
+ })
241
+ })
242
+ }
243
+
179
244
  export async function ensureFromBundle(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; launcher?: AimuxLauncher }> {
180
- if (bundleReady()) return { ok: true, launcher: { kind: 'module', python: bundlePython() } }
245
+ if (bundleReady()) { logInstall(`bundle fast-path: python-bundle already ready\n`); return { ok: true, launcher: { kind: 'module', python: bundlePython() } } }
181
246
  const arch = bundleArch()
182
247
  if (!arch) return { ok: false, error: `当前平台无内置运行时 (platform=${process.platform} arch=${process.arch})` }
248
+ logInstall(`bundle install begin: arch=${arch} platform=${process.platform} home=${mobiusHome()}\n`)
183
249
  onProgress?.({ phase: 'install', detail: `下载内置运行时 (${arch})…` })
184
250
  const dl = await downloadBundle(arch, onProgress)
185
251
  if (!dl.ok || !dl.zipPath) return { ok: false, error: dl.error }
186
252
  onProgress?.({ phase: 'install', detail: '解压内置运行时…' })
187
- const ex = await extractBundle(dl.zipPath)
253
+ const ex = await extractBundle(dl.zipPath, onProgress)
188
254
  try { await fs.unlink(dl.zipPath) } catch {}
189
255
  if (!ex.ok) return { ok: false, error: ex.error }
190
- if (!bundleReady()) return { ok: false, error: '内置运行时解压后仍无法 import aimux' }
256
+ const v = await verifyBundle(onProgress)
257
+ if (!v.ok) return { ok: false, error: v.error ?? '内置运行时解压后仍无法 import aimux' }
258
+ logInstall(`bundle install OK\n`)
191
259
  return { ok: true, launcher: { kind: 'module', python: bundlePython() } }
192
260
  }
193
261
 
@@ -203,8 +271,10 @@ export const downloadBundleForTest = downloadBundle
203
271
 
204
272
  export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; launcher?: AimuxLauncher }> {
205
273
  // Fast-path:venv 里已有 aimux 可执行 → 直接用。
206
- if (existsSync(aimuxExe()) && existsSync(venvPython())) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: { kind: 'exe', path: aimuxExe() } } }
274
+ if (existsSync(aimuxExe()) && existsSync(venvPython())) { logInstall(`ensureAimux fast-path: venv aimux exe present\n`); onProgress?.({ phase: 'ready' }); return { ok: true, launcher: { kind: 'exe', path: aimuxExe() } } }
275
+ logInstall(`\n########## ensureAimux install begin ${new Date().toISOString()} platform=${process.platform} arch=${process.arch} home=${mobiusHome()} ##########\n`)
207
276
  const py = await pythonForAimux(onProgress)
277
+ logInstall(` pythonForAimux → ${py ?? '(null: no system python)'}\n`)
208
278
  let venvError = '未找到 Python。请先安装 Python 3.10+(或安装 uv 后重试)。'
209
279
  if (py) {
210
280
  onProgress?.({ phase: 'venv', detail: `创建 Python 虚拟环境(${py})…` })
@@ -222,9 +292,11 @@ export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Pr
222
292
  }
223
293
  }
224
294
  // ── Plan B 兜底:本地 python/venv 不可用 → 下载内置 python+aimux 运行时 ──
295
+ logInstall(` venv path failed (${venvError || ''}) → falling back to bundle\n`)
225
296
  onProgress?.({ phase: 'install', detail: '本地 Python 不可用,改用内置运行时…' })
226
297
  const bundle = await ensureFromBundle(onProgress)
227
298
  if (bundle.ok && bundle.launcher) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: bundle.launcher } }
299
+ logInstall(`########## ensureAimux FAILED: ${venvError};内置运行时也失败: ${bundle.error} ##########\n`)
228
300
  return { ok: false, error: `${venvError};内置运行时也失败: ${bundle.error}` }
229
301
  }
230
302
 
@@ -450,7 +522,7 @@ export async function startAimuxConnection(opts: { server: string; token: string
450
522
  phase: p.phase === 'ready' ? 'connecting' : p.phase,
451
523
  detail: p.detail || (p.phase === 'ready' ? 'AIMUX 已就绪,准备连接…' : p.phase),
452
524
  }))
453
- if (!ready.ok || !ready.launcher) { onStatus({ state: 'failed', phase: 'idle', detail: ready.error }); return }
525
+ if (!ready.ok || !ready.launcher) { logInstall(`startAimuxConnection giving up: ${ready.error}\n`); onStatus({ state: 'failed', phase: 'idle', detail: `${ready.error} · 日志: ${aimuxLogPath()}` }); return }
454
526
  const identifier = tuiAimuxIdentifier()
455
527
  const launcher = ready.launcher
456
528
  supervisor = new AimuxSupervisor({
@@ -8,7 +8,7 @@
8
8
  * activity, composer, and a persistent context status line.
9
9
  */
10
10
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
11
- import { Box, Static, Text, useInput, useStdout } from 'ink'
11
+ import { Box, Text, useInput, useStdout } from 'ink'
12
12
  import { useChat } from '../hooks/useChat.js'
13
13
  import { MobiusClient } from '../api.js'
14
14
  import { renderMarkdownLines } from '../markdown.js'
@@ -38,6 +38,8 @@ interface TerminalSize {
38
38
 
39
39
  import { createRequire } from 'node:module'
40
40
  const VERSION = createRequire(import.meta.url)('../../package.json').version
41
+ const DEFAULT_COMPOSER_ROWS = 5
42
+ const STATUS_ROWS = 3
41
43
 
42
44
  const SLASH_COMMANDS = [
43
45
  { cmd: '/clear', desc: '清空当前对话,开启新会话' },
@@ -49,6 +51,8 @@ const SLASH_COMMANDS = [
49
51
  export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, aimuxStatus }: ChatProps) {
50
52
  const chat = useChat({ client, ready, resumeSessionId })
51
53
  const [showHelp, setShowHelp] = useState(false)
54
+ const [scrollBack, setScrollBack] = useState(0)
55
+ const [composerRows, setComposerRows] = useState(DEFAULT_COMPOSER_ROWS)
52
56
  const [modelLabel, setModelLabel] = useState<string | null>(null)
53
57
  const terminal = useTerminalSize()
54
58
 
@@ -71,18 +75,13 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
71
75
  return
72
76
  }
73
77
  setShowHelp(false)
78
+ setScrollBack(0)
74
79
  void chat.send(t)
75
80
  }, [chat, runSlash])
76
81
 
77
- // Welcome card is for a truly fresh session only — once there's any
78
- // conversation (or an in-flight message) it disappears. The transcript then
79
- // streams into <Static> below and accumulates into the terminal scrollback
80
- // (the terminal's own scrollback holds history; no in-app pager needed).
81
- const showWelcome = chat.entries.length === 0 && chat.pendingUser === null
82
-
83
82
  // First query of a fresh session triggers the full backend bootstrap (lazy
84
83
  // session creation, worker spawn, context load) before any output streams.
85
- // Label that phase "Initializing for the first query" instead of "Working"
84
+ // Label that phase "第一个问题,正在初始化+全平台同步中,请稍候" instead of "Working"
86
85
  // so it reads as startup rather than a stuck agent. Once the first assistant
87
86
  // output is observed (or the session is a resumed one with prior history),
88
87
  // the indicator falls back to the normal Working label for every turn.
@@ -92,6 +91,29 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
92
91
  // (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
93
92
  // 避免在累积视图里把同一条提问显示多次.
94
93
  const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
94
+ const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
95
+ const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
96
+ const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
97
+ const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 3)
98
+ const fitted = useMemo(
99
+ () => fitTranscript(dedupedEntries, transcriptRows, terminal.columns, scrollBack),
100
+ [dedupedEntries, transcriptRows, terminal.columns, scrollBack],
101
+ )
102
+ const showWelcome = dedupedEntries.length === 0 && chat.pendingUser === null && scrollBack === 0
103
+
104
+ // Keep a history page pinned while new events stream in. At the latest page,
105
+ // new output continues to auto-follow as usual.
106
+ const prevLenRef = useRef(dedupedEntries.length)
107
+ useEffect(() => {
108
+ const previous = prevLenRef.current
109
+ const current = dedupedEntries.length
110
+ prevLenRef.current = current
111
+ if (current > previous && scrollBack > 0) {
112
+ setScrollBack(value => value + current - previous)
113
+ } else if (scrollBack > current) {
114
+ setScrollBack(current)
115
+ }
116
+ }, [dedupedEntries.length, scrollBack])
95
117
 
96
118
  // Show the model's friendly label (e.g. "GPT-5.6-Sol") in the header/status
97
119
  // instead of its opaque key (e.g. "codex:mobiusdefaultaabb").
@@ -106,45 +128,66 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
106
128
  }, [client, ready.prefs.model])
107
129
  const modelDisplay = modelLabel ?? ready.prefs.model ?? 'default'
108
130
 
131
+ useInput((_input, key) => {
132
+ const step = Math.max(1, fitted.entries.length)
133
+ if (key.pageUp) setScrollBack(value => Math.min(dedupedEntries.length, value + step))
134
+ else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
135
+ })
136
+
109
137
  return (
110
- <Box flexDirection="column" width={terminal.isTty ? terminal.columns : undefined} paddingX={1}>
111
- {showWelcome ? (
112
- <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
113
- ) : null}
138
+ <Box
139
+ flexDirection="column"
140
+ width={terminal.isTty ? terminal.columns : undefined}
141
+ height={terminal.isTty ? viewportRows : undefined}
142
+ paddingX={1}
143
+ overflowY="hidden"
144
+ >
145
+ <Box flexDirection="column" flexGrow={1} flexShrink={1} overflowY="hidden">
146
+ {showWelcome
147
+ ? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
148
+ : <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
149
+
150
+ <Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome ? 'flex-start' : 'flex-end'} overflowY="hidden">
151
+ {fitted.hiddenOlder > 0 || scrollBack > 0
152
+ ? <Text dimColor> ↑ {fitted.hiddenOlder > 0 ? `还有 ${fitted.hiddenOlder} 条较早记录 · PageUp 向上翻页` : '已到最早记录 · PageDown 向下翻页'}</Text>
153
+ : null}
154
+ {fitted.entries.map((entry, index) => (
155
+ <EntryAccum key={entry.__id ?? `entry-${fitted.startIndex + index}`} entry={entry} columns={terminal.columns} />
156
+ ))}
157
+ {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
158
+ {fitted.hiddenRecent > 0
159
+ ? <Text dimColor> ↓ PageDown 向下翻页 · 较新 {fitted.hiddenRecent} 条</Text>
160
+ : null}
161
+ </Box>
162
+
163
+ {dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
164
+ ? <Box marginTop={1}><Text dimColor>输入问题开始协作,或输入 <Text color="cyan">/</Text> 查看命令。</Text></Box>
165
+ : null}
114
166
 
115
- {/* <Static> 累积输出: 每个 entry 永久打印进终端 scrollback, 不参与动态重绘.
116
- 新 entry 只追加打印, 历史靠终端自身滚动, 不再需要 in-app 翻页/视窗裁剪. */}
117
- <Static items={dedupedEntries}>
118
- {(entry, index) => (
119
- <EntryAccum key={entry.__id ?? `e${index}`} entry={entry} columns={terminal.columns} />
120
- )}
121
- </Static>
122
-
123
- {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
124
- {chat.typing ? <WorkingIndicator firstQuery={firstQueryInFlight} /> : null}
125
- {chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
126
-
127
- {chat.entries.length === 0 && chat.pendingUser === null && !showHelp
128
- ? <Box marginTop={1}><Text dimColor>输入问题开始协作,或输入 <Text color="cyan">/</Text> 查看命令。</Text></Box>
129
- : null}
130
-
131
- {showHelp ? <HelpBlock commands={SLASH_COMMANDS} /> : null}
132
-
133
- <Composer
134
- onSubmit={onSubmit}
135
- onStop={chat.stop}
136
- onQuit={onQuit}
137
- typing={chat.typing}
138
- commands={SLASH_COMMANDS}
139
- />
140
- <StatusArea
141
- ready={ready}
142
- sessionId={chat.sessionId}
143
- columns={terminal.columns}
144
- webUrl={buildWebUrl(client.server, webUserId, ready, chat.sessionId)}
145
- aimuxStatus={aimuxStatus}
146
- modelDisplay={modelDisplay}
147
- />
167
+ {showHelp ? <HelpBlock commands={SLASH_COMMANDS} /> : null}
168
+ </Box>
169
+
170
+ <Box flexDirection="column" flexShrink={0}>
171
+ {chat.typing ? <WorkingIndicator firstQuery={firstQueryInFlight} /> : null}
172
+ {chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
173
+
174
+ <Composer
175
+ onSubmit={onSubmit}
176
+ onStop={chat.stop}
177
+ onQuit={onQuit}
178
+ typing={chat.typing}
179
+ commands={SLASH_COMMANDS}
180
+ onHeightChange={setComposerRows}
181
+ />
182
+ <StatusArea
183
+ ready={ready}
184
+ sessionId={chat.sessionId}
185
+ columns={terminal.columns}
186
+ webUrl={buildWebUrl(client.server, webUserId, ready, chat.sessionId)}
187
+ aimuxStatus={aimuxStatus}
188
+ modelDisplay={modelDisplay}
189
+ />
190
+ </Box>
148
191
  </Box>
149
192
  )
150
193
  }
@@ -152,8 +195,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
152
195
  function useTerminalSize(): TerminalSize {
153
196
  const { stdout } = useStdout()
154
197
  const read = useCallback((): TerminalSize => ({
155
- columns: Math.max(40, stdout.columns ?? 80),
156
- rows: Math.max(16, stdout.rows ?? 24),
198
+ columns: Math.max(20, stdout.columns ?? 80),
199
+ rows: Math.max(10, stdout.rows ?? 24),
157
200
  isTty: Boolean(stdout.isTTY && stdout.columns && stdout.rows),
158
201
  }), [stdout])
159
202
  const [size, setSize] = useState<TerminalSize>(read)
@@ -169,7 +212,7 @@ function useTerminalSize(): TerminalSize {
169
212
 
170
213
  function WelcomeCard({ ready, columns, resumed, modelDisplay }: { ready: ReadyState; columns: number; resumed: boolean; modelDisplay: string }) {
171
214
  const cwd = compactPath(process.cwd())
172
- const width = Math.max(38, Math.min(68, columns - 4))
215
+ const width = Math.max(18, Math.min(68, columns - 4))
173
216
  const labelWidth = 11
174
217
  return (
175
218
  <Box flexDirection="column">
@@ -202,6 +245,16 @@ function MetaRow({ label, value, hint, labelWidth }: { label: string; value: str
202
245
  )
203
246
  }
204
247
 
248
+ function CompactHeader({ ready, sessionId, columns }: { ready: ReadyState; sessionId: string | null; columns: number }) {
249
+ const context = `${ready.project.name} › ${ready.issue.title}${sessionId ? ` · ${sessionId.slice(0, 8)}` : ''}`
250
+ return (
251
+ <Box justifyContent="space-between">
252
+ <Text bold><Text dimColor>{'>_ '}</Text>Mobius</Text>
253
+ <Text dimColor>{truncateDisplay(context, columns - 14)}</Text>
254
+ </Box>
255
+ )
256
+ }
257
+
205
258
  function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
206
259
  const views = viewsForEntry(entry)
207
260
  return (
@@ -212,7 +265,7 @@ function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
212
265
  }
213
266
 
214
267
  function ViewLine({ view, columns }: { view: EntryView; columns: number }) {
215
- const width = Math.max(20, columns - 4)
268
+ const width = Math.max(8, columns - 4)
216
269
  switch (view.kind) {
217
270
  case 'skip':
218
271
  return null
@@ -365,7 +418,7 @@ function WorkingIndicator({ firstQuery }: { firstQuery: boolean }) {
365
418
  const secs = Math.floor((Date.now() - startedAt.current) / 1000)
366
419
  const elapsed = secs >= 60 ? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s` : `${secs}s`
367
420
  const label = firstQuery
368
- ? `• Initializing for the first query (${elapsed})`
421
+ ? `• 第一个问题,正在初始化+全平台同步中,请稍候 (${elapsed})`
369
422
  : `• Working (${elapsed} · esc to interrupt)`
370
423
  return (
371
424
  <Box marginTop={1}>
@@ -408,15 +461,28 @@ interface ComposerProps {
408
461
  onQuit: () => void
409
462
  typing: boolean
410
463
  commands: { cmd: string; desc: string }[]
464
+ onHeightChange?: (rows: number) => void
411
465
  }
412
466
 
413
- function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps) {
467
+ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange }: ComposerProps) {
414
468
  const [value, setValue] = useState('')
415
469
  const [cursor, setCursor] = useState(0)
416
470
  const [popupIdx, setPopupIdx] = useState(0)
417
471
  const [popupDismissed, setPopupDismissed] = useState(false)
418
472
  const historyRef = useRef<string[]>([])
419
473
  const [histIdx, setHistIdx] = useState<number | null>(null)
474
+ const valueRef = useRef(value)
475
+ const cursorRef = useRef(cursor)
476
+ const pasteRef = useRef<ComposerPasteState>({
477
+ bracketed: false,
478
+ bracketedBuffer: '',
479
+ burstActive: false,
480
+ consecutivePlain: 0,
481
+ lastChunkAt: 0,
482
+ lastChunkLength: 0,
483
+ timer: null,
484
+ })
485
+ const { stdout } = useStdout()
420
486
 
421
487
  const filtered = useMemo(() => {
422
488
  const match = /^(\w*)$/.exec(value.slice(1))
@@ -429,14 +495,142 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
429
495
  const popupOpen = !popupDismissed && value.startsWith('/') && filtered.length > 0 && value.trim() !== filtered[popupIdx]?.cmd
430
496
 
431
497
  function edit(next: string, nextCursor: number) {
498
+ valueRef.current = next
499
+ cursorRef.current = nextCursor
432
500
  setValue(next)
433
501
  setCursor(nextCursor)
434
502
  }
435
503
 
504
+ function insertText(text: string) {
505
+ if (!text) return
506
+ const current = valueRef.current
507
+ const at = clampCursor(current, cursorRef.current)
508
+ const normalized = normalizeComposerPaste(text)
509
+ edit(current.slice(0, at) + normalized + current.slice(at), at + normalized.length)
510
+ }
511
+
512
+ function schedulePasteBurstReset() {
513
+ const state = pasteRef.current
514
+ if (state.timer) clearTimeout(state.timer)
515
+ state.timer = setTimeout(() => {
516
+ state.burstActive = false
517
+ state.consecutivePlain = 0
518
+ state.lastChunkLength = 0
519
+ state.timer = null
520
+ }, pasteBurstWindowMs())
521
+ }
522
+
523
+ function resetPasteBurst() {
524
+ const state = pasteRef.current
525
+ if (state.timer) clearTimeout(state.timer)
526
+ state.bracketed = false
527
+ state.bracketedBuffer = ''
528
+ state.burstActive = false
529
+ state.consecutivePlain = 0
530
+ state.lastChunkAt = 0
531
+ state.lastChunkLength = 0
532
+ state.timer = null
533
+ }
534
+
535
+ function handleBracketedPasteInput(raw: string): boolean {
536
+ const state = pasteRef.current
537
+ const start = findPasteMarker(raw, '200')
538
+ const end = findPasteMarker(raw, '201')
539
+
540
+ if (!state.bracketed && start >= 0) {
541
+ const markerLength = pasteMarkerLength(raw, start, '200')
542
+ const payloadStart = start + markerLength
543
+ const endAfterStart = findPasteMarker(raw, '201', payloadStart)
544
+ if (endAfterStart >= 0) {
545
+ const endLength = pasteMarkerLength(raw, endAfterStart, '201')
546
+ insertText(raw.slice(payloadStart, endAfterStart) + raw.slice(endAfterStart + endLength))
547
+ resetPasteBurst()
548
+ } else {
549
+ state.bracketed = true
550
+ state.bracketedBuffer = raw.slice(payloadStart)
551
+ }
552
+ return true
553
+ }
554
+
555
+ if (!state.bracketed) return false
556
+ if (end >= 0) {
557
+ const endLength = pasteMarkerLength(raw, end, '201')
558
+ state.bracketedBuffer += raw.slice(0, end)
559
+ state.bracketedBuffer += raw.slice(end + endLength)
560
+ insertText(state.bracketedBuffer)
561
+ resetPasteBurst()
562
+ } else {
563
+ state.bracketedBuffer += raw
564
+ }
565
+ return true
566
+ }
567
+
568
+ function moveVertical(direction: -1 | 1) {
569
+ const current = valueRef.current
570
+ const at = clampCursor(current, cursorRef.current)
571
+ const lineStart = current.lastIndexOf('\n', Math.max(0, at - 1)) + 1
572
+ const column = at - lineStart
573
+ const lineEnd = current.indexOf('\n', at)
574
+ const currentEnd = lineEnd < 0 ? current.length : lineEnd
575
+ const targetStart = direction < 0
576
+ ? current.lastIndexOf('\n', Math.max(0, lineStart - 2)) + 1
577
+ : (currentEnd < current.length ? currentEnd + 1 : current.length)
578
+ if (direction < 0 && lineStart === 0) return
579
+ if (direction > 0 && currentEnd === current.length) return
580
+ const targetEndRel = current.indexOf('\n', targetStart)
581
+ const targetEnd = targetEndRel < 0 ? current.length : targetEndRel
582
+ edit(current, targetStart + Math.min(column, targetEnd - targetStart))
583
+ }
584
+
585
+ function moveCursor(next: number) {
586
+ cursorRef.current = next
587
+ setCursor(next)
588
+ }
589
+
590
+ useEffect(() => {
591
+ if (!stdout.isTTY) return
592
+ // Match Codex/crossterm: request explicit paste events so embedded Enters
593
+ // stay inside the textarea. The burst detector below remains the fallback
594
+ // for terminals and remote chains that ignore bracketed-paste mode.
595
+ stdout.write('\x1b[?2004h')
596
+ return () => { stdout.write('\x1b[?2004l') }
597
+ }, [stdout])
598
+
599
+ useEffect(() => () => resetPasteBurst(), [])
600
+
436
601
  useInput((input, key) => {
602
+ const now = Date.now()
437
603
  const escape = isEscapeKeypress(input, key)
438
604
  if (typing && escape) { void onStop(); return }
439
605
 
606
+ if (pasteRef.current.bracketed && key.return) {
607
+ pasteRef.current.bracketedBuffer += '\n'
608
+ return
609
+ }
610
+
611
+ // Ink 5 incorrectly marks a plain carriage-return as Shift because "\r" is
612
+ // unchanged by toUpperCase(). Detect enhanced-keyboard Shift+Enter from its
613
+ // raw sequence instead of trusting key.shift on an ordinary Enter event.
614
+ if (isEnhancedNewlineInput(input)) { insertText('\n'); return }
615
+
616
+ // Terminals that support bracketed paste wrap the payload in ESC[200~ / ESC[201~.
617
+ // Ink's parser strips a leading ESC, so accept both the raw and stripped marker forms.
618
+ if (handleBracketedPasteInput(input)) return
619
+
620
+ const current = valueRef.current
621
+ const at = clampCursor(current, cursorRef.current)
622
+
623
+ // A few terminals (notably ConPTY and some SSH/tmux combinations) do not expose
624
+ // bracketed paste. They deliver a paste as fast text chunks separated by Enter events.
625
+ // Track that short burst so those Enters become newlines instead of submitting each line.
626
+ if (!key.return && input && !key.ctrl && !key.meta && /[\r\n]/.test(input)) {
627
+ insertText(input)
628
+ pasteRef.current.burstActive = false
629
+ pasteRef.current.lastChunkAt = now
630
+ pasteRef.current.lastChunkLength = 0
631
+ return
632
+ }
633
+
440
634
  if (popupOpen) {
441
635
  if (key.upArrow) { setPopupIdx(i => (i <= 0 ? filtered.length - 1 : i - 1)); return }
442
636
  if (key.downArrow) { setPopupIdx(i => (i + 1) % filtered.length); return }
@@ -448,9 +642,22 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
448
642
  }
449
643
 
450
644
  if (key.return) {
451
- if (value.trim()) {
452
- historyRef.current.push(value)
453
- onSubmit(value)
645
+ const state = pasteRef.current
646
+ const inPasteBurst = state.burstActive || (
647
+ state.lastChunkLength > 1 && now - state.lastChunkAt <= pasteBurstWindowMs()
648
+ )
649
+ if (inPasteBurst && !key.ctrl && !key.meta) {
650
+ insertText('\n')
651
+ state.burstActive = true
652
+ state.lastChunkAt = now
653
+ state.lastChunkLength = 0
654
+ schedulePasteBurstReset()
655
+ return
656
+ }
657
+ const submitted = valueRef.current
658
+ if (submitted.trim()) {
659
+ historyRef.current.push(submitted)
660
+ onSubmit(submitted)
454
661
  edit('', 0)
455
662
  setHistIdx(null)
456
663
  }
@@ -459,14 +666,24 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
459
666
  if (key.ctrl && input === 'c') { typing ? void onStop() : onQuit(); return }
460
667
  // Ink reports the terminal Backspace key (\x7f) as `key.delete`; handle both
461
668
  // as a backward delete so Backspace works at the end of the input.
462
- if (key.backspace || key.delete) {
463
- if (cursor > 0) edit(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1)
669
+ if (key.backspace || key.delete || (key.ctrl && (input === 'h' || input === 'w'))) {
670
+ if (at > 0) {
671
+ if (key.ctrl && input === 'w') {
672
+ const before = current.slice(0, at)
673
+ const match = before.match(/\S+\s*$/)
674
+ const cut = match ? match[0].length : 0
675
+ edit(current.slice(0, at - cut) + current.slice(at), at - cut)
676
+ } else {
677
+ const previous = previousCursorBoundary(current, at)
678
+ edit(current.slice(0, previous) + current.slice(at), previous)
679
+ }
680
+ }
464
681
  return
465
682
  }
466
- if (key.leftArrow) { setCursor(current => Math.max(0, current - 1)); return }
467
- if (key.rightArrow) { setCursor(current => Math.min(value.length, current + 1)); return }
683
+ if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
684
+ if (key.rightArrow) { moveCursor(nextCursorBoundary(current, at)); return }
468
685
 
469
- const onFirstLine = value.slice(0, cursor).indexOf('\n') === -1
686
+ const onFirstLine = current.slice(0, at).indexOf('\n') === -1
470
687
  if (key.upArrow && onFirstLine) {
471
688
  const history = historyRef.current
472
689
  if (history.length) {
@@ -476,6 +693,7 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
476
693
  }
477
694
  return
478
695
  }
696
+ if (key.upArrow) { moveVertical(-1); return }
479
697
  if (key.downArrow && histIdx !== null) {
480
698
  const history = historyRef.current
481
699
  const next = histIdx + 1
@@ -483,22 +701,37 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
483
701
  else { setHistIdx(next); edit(history[next], history[next].length) }
484
702
  return
485
703
  }
486
- if (key.ctrl && input === 'a') { setCursor(0); return }
487
- if (key.ctrl && input === 'e') { setCursor(value.length); return }
704
+ if (key.downArrow) { moveVertical(1); return }
705
+ if (key.ctrl && input === 'a') { moveCursor(0); return }
706
+ if (key.ctrl && input === 'e') { moveCursor(current.length); return }
488
707
  if (key.ctrl && input === 'u') { edit('', 0); return }
489
- if (key.ctrl && input === 'k') { edit(value.slice(0, cursor), cursor); return }
490
- if (key.ctrl && input === 'j') { edit(value.slice(0, cursor) + '\n' + value.slice(cursor), cursor + 1); return }
708
+ if (key.ctrl && input === 'k') { edit(current.slice(0, at), at); return }
709
+ if (key.ctrl && input === 'j') { insertText('\n'); return }
491
710
  if (key.ctrl || key.meta || escape || !input) return
492
- edit(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length)
711
+
712
+ const chunkAt = pasteRef.current
713
+ const continuesBurst = chunkAt.lastChunkAt > 0 && now - chunkAt.lastChunkAt <= pasteBurstWindowMs()
714
+ chunkAt.consecutivePlain = continuesBurst ? chunkAt.consecutivePlain + 1 : 1
715
+ if (input.length > 1 || chunkAt.consecutivePlain >= 3) {
716
+ chunkAt.burstActive = true
717
+ schedulePasteBurstReset()
718
+ }
719
+ insertText(input)
720
+ chunkAt.lastChunkAt = now
721
+ chunkAt.lastChunkLength = input.length
493
722
  })
494
723
 
495
- const lines = value.split('\n')
496
- const lineIdx = value.slice(0, cursor).match(/\n/g)?.length ?? 0
497
- const col = cursor - (value.slice(0, cursor).lastIndexOf('\n') + 1)
498
- const currentLine = lines[lineIdx] ?? ''
499
- const beforeCursor = currentLine.slice(0, col)
500
- const atCursor = currentLine.slice(col, col + 1)
501
- const afterCursor = currentLine.slice(col + 1)
724
+ const composerWidth = Math.max(12, (stdout.columns ?? 80) - 9)
725
+ const wrapped = wrapComposerLines(value, composerWidth)
726
+ const visualCursor = findComposerCursorLine(wrapped, clampCursor(value, cursor))
727
+ const maxRows = Math.max(3, Math.min(10, Math.floor((stdout.rows ?? 24) * 0.42)))
728
+ const firstVisible = Math.max(0, Math.min(visualCursor - maxRows + 1, visualCursor))
729
+ const visible = wrapped.slice(firstVisible, firstVisible + maxRows)
730
+ const renderedRows = 4 + visible.length + (popupOpen ? filtered.length + 1 : 0)
731
+
732
+ useEffect(() => {
733
+ onHeightChange?.(renderedRows)
734
+ }, [onHeightChange, renderedRows])
502
735
 
503
736
  return (
504
737
  <Box flexDirection="column" marginTop={1}>
@@ -515,28 +748,141 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
515
748
  ))}
516
749
  </Box>
517
750
  ) : null}
518
- <Box>
519
- <Text bold>{'› '}</Text>
520
- <Text>
521
- {lines.map((line, index) => {
522
- if (index < lineIdx) return <Text key={index}>{line}{'\n'}</Text>
523
- if (index === lineIdx) {
524
- return (
525
- <Text key={index}>
526
- {beforeCursor}<Text backgroundColor="white" color="black">{atCursor || ' '}</Text>{afterCursor}
527
- {index < lines.length - 1 ? '\n' : ''}
528
- </Text>
529
- )
530
- }
531
- return <Text key={index}>{'\n'}{line}</Text>
751
+ <Box
752
+ flexDirection="column"
753
+ borderStyle="round"
754
+ borderColor={typing ? 'yellow' : 'gray'}
755
+ borderDimColor={!typing}
756
+ paddingX={1}
757
+ >
758
+ <Box flexDirection="column" height={Math.min(maxRows, wrapped.length)} overflow="hidden">
759
+ {visible.map((line, index) => {
760
+ const realIndex = firstVisible + index
761
+ const isCursorLine = realIndex === visualCursor
762
+ const c = isCursorLine ? clampCursor(value, cursor) - line.start : -1
763
+ const before = isCursorLine ? line.text.slice(0, Math.max(0, c)) : line.text
764
+ const atCursor = isCursorLine ? line.text.slice(Math.max(0, c), Math.max(0, c) + 1) : ''
765
+ const after = isCursorLine ? line.text.slice(Math.max(0, c) + 1) : ''
766
+ return (
767
+ <Text key={`${line.start}-${realIndex}`}>
768
+ {index === 0 ? <Text bold>{firstVisible > 0 ? '… ' : '› '}</Text> : <Text>{' '}</Text>}
769
+ {isCursorLine
770
+ ? <>{before}<Text backgroundColor="white" color="black">{atCursor || ' '}</Text>{after}</>
771
+ : line.text}
772
+ {value === '' && index === 0 ? <Text dimColor> 输入问题或 / 命令</Text> : null}
773
+ </Text>
774
+ )
532
775
  })}
533
- {value === '' ? <Text dimColor> 输入问题或 / 命令</Text> : null}
534
- </Text>
776
+ </Box>
777
+ <Box justifyContent="space-between">
778
+ <Text dimColor>{(stdout.columns ?? 80) >= 58 ? 'Enter 发送 · Shift+Enter / Ctrl+J 换行' : 'Enter 发送 · Ctrl+J 换行'}</Text>
779
+ <Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
780
+ </Box>
535
781
  </Box>
536
782
  </Box>
537
783
  )
538
784
  }
539
785
 
786
+ interface ComposerPasteState {
787
+ bracketed: boolean
788
+ bracketedBuffer: string
789
+ burstActive: boolean
790
+ consecutivePlain: number
791
+ lastChunkAt: number
792
+ lastChunkLength: number
793
+ timer: ReturnType<typeof setTimeout> | null
794
+ }
795
+
796
+ function pasteBurstWindowMs(): number {
797
+ return process.platform === 'win32' ? 60 : 20
798
+ }
799
+
800
+ function normalizeComposerPaste(text: string): string {
801
+ return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
802
+ }
803
+
804
+ function isEnhancedNewlineInput(input: string): boolean {
805
+ return /^\[(?:13|27);2(?:u|~)$/.test(input) || input === '\x1b\r'
806
+ }
807
+
808
+ function findPasteMarker(input: string, code: '200' | '201', from = 0): number {
809
+ const raw = `\x1b[${code}~`
810
+ const stripped = `[${code}~`
811
+ const rawAt = input.indexOf(raw, from)
812
+ const strippedAt = input.indexOf(stripped, from)
813
+ if (rawAt < 0) return strippedAt
814
+ if (strippedAt < 0) return rawAt
815
+ return Math.min(rawAt, strippedAt)
816
+ }
817
+
818
+ function pasteMarkerLength(input: string, at: number, code: '200' | '201'): number {
819
+ return input.startsWith(`\x1b[${code}~`, at) ? 6 : 5
820
+ }
821
+
822
+ function clampCursor(text: string, cursor: number): number {
823
+ let at = Math.max(0, Math.min(text.length, cursor))
824
+ while (at > 0 && at < text.length && /[\uDC00-\uDFFF]/.test(text[at])) at--
825
+ return at
826
+ }
827
+
828
+ function previousCursorBoundary(text: string, cursor: number): number {
829
+ const at = clampCursor(text, cursor)
830
+ if (at <= 0) return 0
831
+ const code = text.charCodeAt(at - 1)
832
+ return at - (code >= 0xDC00 && code <= 0xDFFF ? 2 : 1)
833
+ }
834
+
835
+ function nextCursorBoundary(text: string, cursor: number): number {
836
+ const at = clampCursor(text, cursor)
837
+ if (at >= text.length) return text.length
838
+ const code = text.charCodeAt(at)
839
+ return at + (code >= 0xD800 && code <= 0xDBFF ? 2 : 1)
840
+ }
841
+
842
+ interface ComposerLine { text: string; start: number; end: number }
843
+
844
+ function wrapComposerLines(text: string, width: number): ComposerLine[] {
845
+ if (!text) return [{ text: '', start: 0, end: 0 }]
846
+ const result: ComposerLine[] = []
847
+ let lineStart = 0
848
+ let lineText = ''
849
+ let lineWidth = 0
850
+ for (let i = 0; i < text.length;) {
851
+ const ch = text[i]
852
+ if (ch === '\n') {
853
+ result.push({ text: lineText, start: lineStart, end: i })
854
+ lineText = ''
855
+ lineWidth = 0
856
+ lineStart = i + 1
857
+ i++
858
+ continue
859
+ }
860
+ const next = nextCursorBoundary(text, i)
861
+ const piece = text.slice(i, next)
862
+ const pieceWidth = Math.max(1, displayWidth(piece))
863
+ if (lineText && lineWidth + pieceWidth > width) {
864
+ result.push({ text: lineText, start: lineStart, end: i })
865
+ lineText = ''
866
+ lineWidth = 0
867
+ lineStart = i
868
+ }
869
+ lineText += piece
870
+ lineWidth += pieceWidth
871
+ i = next
872
+ }
873
+ result.push({ text: lineText, start: lineStart, end: text.length })
874
+ return result
875
+ }
876
+
877
+ function findComposerCursorLine(lines: ComposerLine[], cursor: number): number {
878
+ const at = Math.max(0, cursor)
879
+ for (let i = 0; i < lines.length; i++) {
880
+ const line = lines[i]
881
+ if (at < line.end || (at === line.end && (i === lines.length - 1 || lines[i + 1].start > at))) return i
882
+ }
883
+ return Math.max(0, lines.length - 1)
884
+ }
885
+
540
886
  function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDisplay }: {
541
887
  ready: ReadyState
542
888
  sessionId: string | null
@@ -608,8 +954,9 @@ function compactPath(path: string): string {
608
954
  }
609
955
 
610
956
  function truncateDisplay(value: string, maxLength: number): string {
611
- if (value.length <= maxLength) return value
612
- return maxLength <= 1 ? '…' : `${value.slice(0, maxLength - 1)}…`
957
+ const limit = Math.max(1, Math.floor(maxLength))
958
+ if (value.length <= limit) return value
959
+ return limit <= 1 ? '…' : `${value.slice(0, limit - 1)}…`
613
960
  }
614
961
 
615
962
  function buildWebUrl(server: string, webUserId: string, ready: ReadyState, sessionId: string | null): string {
@@ -658,5 +1005,59 @@ function isWideCodepoint(code: number): boolean {
658
1005
  )
659
1006
  }
660
1007
 
661
- // (fitTranscript / blockRows / wrappedRows 视窗裁剪 + in-app 翻页逻辑已移除:
662
- // transcript 现由 <Static> 累积进终端 scrollback, 历史靠终端自身滚动.)
1008
+ function wrappedRows(text: string, width: number): number {
1009
+ const safeWidth = Math.max(1, width)
1010
+ return text.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(displayWidth(line) / safeWidth)), 0)
1011
+ }
1012
+
1013
+ function entryRows(entry: AnyEntry, columns: number): number {
1014
+ const width = Math.max(8, columns - 4)
1015
+ return Math.max(1, viewsForEntry(entry).reduce((sum, view) => {
1016
+ switch (view.kind) {
1017
+ case 'skip': return sum
1018
+ case 'user': return sum + 1 + wrappedRows(view.text, width - 2)
1019
+ case 'assistant': {
1020
+ const rows = renderMarkdownLines(view.text).reduce((total, line) => {
1021
+ return total + (line.code ? 1 : wrappedRows(line.text || ' ', width - 2))
1022
+ }, 0)
1023
+ return sum + 1 + rows
1024
+ }
1025
+ case 'tool_call': return sum + 2 + (view.result ? 1 : 0)
1026
+ case 'tool_result': return sum + headTailLines(view.text, width - 4, 5).length
1027
+ case 'code_edit':
1028
+ return sum + 2 + wrappedRows(view.filePath || '(未指定文件)', width - 4)
1029
+ + wrappedRows(view.oldString, width - 4) + wrappedRows(view.newString, width - 4)
1030
+ case 'write_file':
1031
+ return sum + 2 + wrappedRows(view.filePath || '(未指定文件)', width - 4)
1032
+ + wrappedRows(view.content, width - 4)
1033
+ case 'reasoning': return sum + 1 + clampLines(view.text, width - 4, 2).length
1034
+ case 'system': return sum + 1
1035
+ case 'error': return sum + 1 + wrappedRows(view.text, width - 2)
1036
+ default: return sum
1037
+ }
1038
+ }, 0))
1039
+ }
1040
+
1041
+ function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
1042
+ entries: AnyEntry[]
1043
+ hiddenOlder: number
1044
+ hiddenRecent: number
1045
+ startIndex: number
1046
+ } {
1047
+ const tail = Math.max(0, entries.length - scrollBack)
1048
+ const available = tail === 0 ? [] : entries.slice(0, tail)
1049
+ let rows = 0
1050
+ let first = available.length
1051
+ for (let index = available.length - 1; index >= 0; index--) {
1052
+ const nextRows = entryRows(available[index], columns)
1053
+ if (first < available.length && rows + nextRows > rowBudget) break
1054
+ rows += nextRows
1055
+ first = index
1056
+ }
1057
+ return {
1058
+ entries: available.slice(first),
1059
+ hiddenOlder: first,
1060
+ hiddenRecent: entries.length - tail,
1061
+ startIndex: first,
1062
+ }
1063
+ }
@@ -109,6 +109,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
109
109
  const aliveRef = useRef(true)
110
110
  const stoppedRef = useRef(false)
111
111
  const doConnectRef = useRef<(sid: string) => void>(() => {})
112
+ const connectionGenerationRef = useRef(0)
112
113
 
113
114
  const updateTyping = useCallback((active: boolean) => {
114
115
  typingRef.current = active
@@ -147,6 +148,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
147
148
  // ── SSE connection ────────────────────────────────────────────────────────
148
149
  const connect = useCallback((sid: string) => {
149
150
  if (process.env.MOBIUS_TUI_DEBUG) console.error('[connect]', sid)
151
+ const generation = ++connectionGenerationRef.current
150
152
  sseRef.current?.close()
151
153
  if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null }
152
154
  const url = `${client.server}/api/sessions/${encodeURIComponent(sid)}/events?token=${encodeURIComponent(client.token)}`
@@ -183,6 +185,10 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
183
185
  },
184
186
  onError: (msg) => setError(msg),
185
187
  onClose: () => {
188
+ // `connect()` closes the previous stream before installing its
189
+ // replacement. Ignore that superseded stream's eventual close callback,
190
+ // otherwise it can schedule a timer that tears down the fresh stream.
191
+ if (generation !== connectionGenerationRef.current) return
186
192
  // Reconnect with exponential backoff as long as the session is still
187
193
  // alive; stop once it ends (alive=false) or after a few failed tries.
188
194
  if (stoppedRef.current || !aliveRef.current) return
@@ -201,6 +207,25 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
201
207
  }, [client.server, client.token, appendEntries, setHistory, updateTyping])
202
208
  doConnectRef.current = connect
203
209
 
210
+ const ensureSseForSend = useCallback((sid: string): boolean => {
211
+ // A completed worker reports alive=false. If its SSE stream is later closed
212
+ // by an idle proxy, onClose deliberately stops reconnecting. Sending a new
213
+ // turn revives the same session, so reopen the stream before dispatching the
214
+ // message; otherwise the backend and web UI advance while this TUI remains
215
+ // attached to a permanently closed connection.
216
+ aliveRef.current = true
217
+ reconnectAttemptRef.current = 0
218
+ if (reconnectTimerRef.current) {
219
+ clearTimeout(reconnectTimerRef.current)
220
+ reconnectTimerRef.current = null
221
+ }
222
+ if (!sseRef.current || sseRef.current.isClosed()) {
223
+ connect(sid)
224
+ return true
225
+ }
226
+ return false
227
+ }, [connect])
228
+
204
229
  // Connect immediately when a resume session is provided, or after we create one.
205
230
  useEffect(() => {
206
231
  if (sessionId && !sseRef.current) connect(sessionId)
@@ -355,8 +380,11 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
355
380
  setSending(true)
356
381
  try {
357
382
  const sid = await ensureSession()
358
- // SSE may not be connected yet for a freshly created session; give it a tick.
359
- if (!sseRef.current) await new Promise(r => setTimeout(r, 200))
383
+ // Fresh sessions connect via the sessionId effect. Resumed sessions may
384
+ // have a permanently closed idle stream after their worker exited; revive
385
+ // that stream explicitly before POSTing so this turn cannot be missed.
386
+ const reopenedSse = ensureSseForSend(sid)
387
+ if (reopenedSse || !sseRef.current) await new Promise(r => setTimeout(r, 200))
360
388
  if (process.env.MOBIUS_TUI_DEBUG) console.error('[send-post]', sid, 'sse=', !!sseRef.current, 'closed=', sseRef.current?.isClosed())
361
389
  const reqId = `tui-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
362
390
  await sendWithRetry(() => client.sendMessage(sid, body, reqId))
@@ -372,7 +400,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
372
400
  setSending(false)
373
401
  pollNowRef.current?.()
374
402
  }
375
- }, [sending, ensureSession, client, updateTyping])
403
+ }, [sending, ensureSession, ensureSseForSend, client, updateTyping])
376
404
 
377
405
  const stop = useCallback(async () => {
378
406
  if (!sessionId) return