@mobius-os/mobius 0.3.9 → 0.3.15

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.15",
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'
@@ -17,7 +17,7 @@ import type { ReadyState } from './PrepScreen.js'
17
17
  import type { AnyEntry } from '../types.js'
18
18
  import type { AimuxStatus } from '../aimux.js'
19
19
  import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
20
- import { isEscapeKeypress } from './primitives.js'
20
+ import { isEscapeKeypress, isMouseInput, useMouseWheel } from './primitives.js'
21
21
 
22
22
  interface ChatProps {
23
23
  client: MobiusClient
@@ -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,86 @@ 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
+
137
+ // Mouse wheel: up scrolls back through history, down returns toward the
138
+ // latest, mirroring PageUp/PageDown but in small fixed steps. Handled on the
139
+ // Ink event emitter (not useInput) so the sequence can be buffered across
140
+ // read() chunks; the Composer guards against inserting mouse bytes as text.
141
+ useMouseWheel((delta) => {
142
+ if (delta === 0) return
143
+ const step = 3
144
+ setScrollBack(value => Math.min(dedupedEntries.length, Math.max(0, value + delta * step)))
145
+ })
146
+
147
+ const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
148
+ ? fitted.hiddenOlder > 0
149
+ ? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp 向上翻页`
150
+ : '已到最早记录 · 滚轮/PageDown 向下翻页'
151
+ : null
152
+
109
153
  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}
154
+ <Box
155
+ flexDirection="column"
156
+ width={terminal.isTty ? terminal.columns : undefined}
157
+ height={terminal.isTty ? viewportRows : undefined}
158
+ paddingX={1}
159
+ overflowY="hidden"
160
+ >
161
+ <Box flexDirection="column" flexGrow={1} flexShrink={1} overflowY="hidden">
162
+ {showWelcome
163
+ ? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
164
+ : <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
165
+
166
+ {/* Older-records hint is pinned OUTSIDE the flex-end scroll box so it is
167
+ always the first line of the transcript, spanning the full width,
168
+ instead of floating mid-screen when the transcript has spare rows. */}
169
+ {olderHint !== null
170
+ ? <Box width="100%" flexShrink={0}><Text dimColor> {olderHint}</Text></Box>
171
+ : null}
172
+
173
+ <Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome ? 'flex-start' : 'flex-end'} overflowY="hidden">
174
+ {fitted.entries.map((entry, index) => (
175
+ <EntryAccum key={entry.__id ?? `entry-${fitted.startIndex + index}`} entry={entry} columns={terminal.columns} />
176
+ ))}
177
+ {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
178
+ {fitted.hiddenRecent > 0
179
+ ? <Box width="100%" flexShrink={0}><Text dimColor> ↓ 滚轮/PageDown 向下翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
180
+ : null}
181
+ </Box>
114
182
 
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
- />
183
+ {dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
184
+ ? <Box marginTop={1}><Text dimColor>输入问题开始协作,或输入 <Text color="cyan">/</Text> 查看命令。</Text></Box>
185
+ : null}
186
+
187
+ {showHelp ? <HelpBlock commands={SLASH_COMMANDS} /> : null}
188
+ </Box>
189
+
190
+ <Box flexDirection="column" flexShrink={0}>
191
+ {chat.typing ? <WorkingIndicator firstQuery={firstQueryInFlight} /> : null}
192
+ {chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
193
+
194
+ <Composer
195
+ onSubmit={onSubmit}
196
+ onStop={chat.stop}
197
+ onQuit={onQuit}
198
+ typing={chat.typing}
199
+ commands={SLASH_COMMANDS}
200
+ onHeightChange={setComposerRows}
201
+ />
202
+ <StatusArea
203
+ ready={ready}
204
+ sessionId={chat.sessionId}
205
+ columns={terminal.columns}
206
+ webUrl={buildWebUrl(client.server, webUserId, ready, chat.sessionId)}
207
+ aimuxStatus={aimuxStatus}
208
+ modelDisplay={modelDisplay}
209
+ />
210
+ </Box>
148
211
  </Box>
149
212
  )
150
213
  }
@@ -152,8 +215,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
152
215
  function useTerminalSize(): TerminalSize {
153
216
  const { stdout } = useStdout()
154
217
  const read = useCallback((): TerminalSize => ({
155
- columns: Math.max(40, stdout.columns ?? 80),
156
- rows: Math.max(16, stdout.rows ?? 24),
218
+ columns: Math.max(20, stdout.columns ?? 80),
219
+ rows: Math.max(10, stdout.rows ?? 24),
157
220
  isTty: Boolean(stdout.isTTY && stdout.columns && stdout.rows),
158
221
  }), [stdout])
159
222
  const [size, setSize] = useState<TerminalSize>(read)
@@ -169,7 +232,7 @@ function useTerminalSize(): TerminalSize {
169
232
 
170
233
  function WelcomeCard({ ready, columns, resumed, modelDisplay }: { ready: ReadyState; columns: number; resumed: boolean; modelDisplay: string }) {
171
234
  const cwd = compactPath(process.cwd())
172
- const width = Math.max(38, Math.min(68, columns - 4))
235
+ const width = Math.max(18, Math.min(68, columns - 4))
173
236
  const labelWidth = 11
174
237
  return (
175
238
  <Box flexDirection="column">
@@ -202,6 +265,16 @@ function MetaRow({ label, value, hint, labelWidth }: { label: string; value: str
202
265
  )
203
266
  }
204
267
 
268
+ function CompactHeader({ ready, sessionId, columns }: { ready: ReadyState; sessionId: string | null; columns: number }) {
269
+ const context = `${ready.project.name} › ${ready.issue.title}${sessionId ? ` · ${sessionId.slice(0, 8)}` : ''}`
270
+ return (
271
+ <Box justifyContent="space-between">
272
+ <Text bold><Text dimColor>{'>_ '}</Text>Mobius</Text>
273
+ <Text dimColor>{truncateDisplay(context, columns - 14)}</Text>
274
+ </Box>
275
+ )
276
+ }
277
+
205
278
  function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
206
279
  const views = viewsForEntry(entry)
207
280
  return (
@@ -212,7 +285,7 @@ function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
212
285
  }
213
286
 
214
287
  function ViewLine({ view, columns }: { view: EntryView; columns: number }) {
215
- const width = Math.max(20, columns - 4)
288
+ const width = Math.max(8, columns - 4)
216
289
  switch (view.kind) {
217
290
  case 'skip':
218
291
  return null
@@ -365,7 +438,7 @@ function WorkingIndicator({ firstQuery }: { firstQuery: boolean }) {
365
438
  const secs = Math.floor((Date.now() - startedAt.current) / 1000)
366
439
  const elapsed = secs >= 60 ? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s` : `${secs}s`
367
440
  const label = firstQuery
368
- ? `• Initializing for the first query (${elapsed})`
441
+ ? `• 第一个问题,正在初始化+全平台同步中,请稍候 (${elapsed})`
369
442
  : `• Working (${elapsed} · esc to interrupt)`
370
443
  return (
371
444
  <Box marginTop={1}>
@@ -408,15 +481,28 @@ interface ComposerProps {
408
481
  onQuit: () => void
409
482
  typing: boolean
410
483
  commands: { cmd: string; desc: string }[]
484
+ onHeightChange?: (rows: number) => void
411
485
  }
412
486
 
413
- function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps) {
487
+ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange }: ComposerProps) {
414
488
  const [value, setValue] = useState('')
415
489
  const [cursor, setCursor] = useState(0)
416
490
  const [popupIdx, setPopupIdx] = useState(0)
417
491
  const [popupDismissed, setPopupDismissed] = useState(false)
418
492
  const historyRef = useRef<string[]>([])
419
493
  const [histIdx, setHistIdx] = useState<number | null>(null)
494
+ const valueRef = useRef(value)
495
+ const cursorRef = useRef(cursor)
496
+ const pasteRef = useRef<ComposerPasteState>({
497
+ bracketed: false,
498
+ bracketedBuffer: '',
499
+ burstActive: false,
500
+ consecutivePlain: 0,
501
+ lastChunkAt: 0,
502
+ lastChunkLength: 0,
503
+ timer: null,
504
+ })
505
+ const { stdout } = useStdout()
420
506
 
421
507
  const filtered = useMemo(() => {
422
508
  const match = /^(\w*)$/.exec(value.slice(1))
@@ -429,14 +515,143 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
429
515
  const popupOpen = !popupDismissed && value.startsWith('/') && filtered.length > 0 && value.trim() !== filtered[popupIdx]?.cmd
430
516
 
431
517
  function edit(next: string, nextCursor: number) {
518
+ valueRef.current = next
519
+ cursorRef.current = nextCursor
432
520
  setValue(next)
433
521
  setCursor(nextCursor)
434
522
  }
435
523
 
524
+ function insertText(text: string) {
525
+ if (!text) return
526
+ const current = valueRef.current
527
+ const at = clampCursor(current, cursorRef.current)
528
+ const normalized = normalizeComposerPaste(text)
529
+ edit(current.slice(0, at) + normalized + current.slice(at), at + normalized.length)
530
+ }
531
+
532
+ function schedulePasteBurstReset() {
533
+ const state = pasteRef.current
534
+ if (state.timer) clearTimeout(state.timer)
535
+ state.timer = setTimeout(() => {
536
+ state.burstActive = false
537
+ state.consecutivePlain = 0
538
+ state.lastChunkLength = 0
539
+ state.timer = null
540
+ }, pasteBurstWindowMs())
541
+ }
542
+
543
+ function resetPasteBurst() {
544
+ const state = pasteRef.current
545
+ if (state.timer) clearTimeout(state.timer)
546
+ state.bracketed = false
547
+ state.bracketedBuffer = ''
548
+ state.burstActive = false
549
+ state.consecutivePlain = 0
550
+ state.lastChunkAt = 0
551
+ state.lastChunkLength = 0
552
+ state.timer = null
553
+ }
554
+
555
+ function handleBracketedPasteInput(raw: string): boolean {
556
+ const state = pasteRef.current
557
+ const start = findPasteMarker(raw, '200')
558
+ const end = findPasteMarker(raw, '201')
559
+
560
+ if (!state.bracketed && start >= 0) {
561
+ const markerLength = pasteMarkerLength(raw, start, '200')
562
+ const payloadStart = start + markerLength
563
+ const endAfterStart = findPasteMarker(raw, '201', payloadStart)
564
+ if (endAfterStart >= 0) {
565
+ const endLength = pasteMarkerLength(raw, endAfterStart, '201')
566
+ insertText(raw.slice(payloadStart, endAfterStart) + raw.slice(endAfterStart + endLength))
567
+ resetPasteBurst()
568
+ } else {
569
+ state.bracketed = true
570
+ state.bracketedBuffer = raw.slice(payloadStart)
571
+ }
572
+ return true
573
+ }
574
+
575
+ if (!state.bracketed) return false
576
+ if (end >= 0) {
577
+ const endLength = pasteMarkerLength(raw, end, '201')
578
+ state.bracketedBuffer += raw.slice(0, end)
579
+ state.bracketedBuffer += raw.slice(end + endLength)
580
+ insertText(state.bracketedBuffer)
581
+ resetPasteBurst()
582
+ } else {
583
+ state.bracketedBuffer += raw
584
+ }
585
+ return true
586
+ }
587
+
588
+ function moveVertical(direction: -1 | 1) {
589
+ const current = valueRef.current
590
+ const at = clampCursor(current, cursorRef.current)
591
+ const lineStart = current.lastIndexOf('\n', Math.max(0, at - 1)) + 1
592
+ const column = at - lineStart
593
+ const lineEnd = current.indexOf('\n', at)
594
+ const currentEnd = lineEnd < 0 ? current.length : lineEnd
595
+ const targetStart = direction < 0
596
+ ? current.lastIndexOf('\n', Math.max(0, lineStart - 2)) + 1
597
+ : (currentEnd < current.length ? currentEnd + 1 : current.length)
598
+ if (direction < 0 && lineStart === 0) return
599
+ if (direction > 0 && currentEnd === current.length) return
600
+ const targetEndRel = current.indexOf('\n', targetStart)
601
+ const targetEnd = targetEndRel < 0 ? current.length : targetEndRel
602
+ edit(current, targetStart + Math.min(column, targetEnd - targetStart))
603
+ }
604
+
605
+ function moveCursor(next: number) {
606
+ cursorRef.current = next
607
+ setCursor(next)
608
+ }
609
+
610
+ useEffect(() => {
611
+ if (!stdout.isTTY) return
612
+ // Match Codex/crossterm: request explicit paste events so embedded Enters
613
+ // stay inside the textarea. The burst detector below remains the fallback
614
+ // for terminals and remote chains that ignore bracketed-paste mode.
615
+ stdout.write('\x1b[?2004h')
616
+ return () => { stdout.write('\x1b[?2004l') }
617
+ }, [stdout])
618
+
619
+ useEffect(() => () => resetPasteBurst(), [])
620
+
436
621
  useInput((input, key) => {
622
+ if (isMouseInput(input)) return // mouse events must never become typed text
623
+ const now = Date.now()
437
624
  const escape = isEscapeKeypress(input, key)
438
625
  if (typing && escape) { void onStop(); return }
439
626
 
627
+ if (pasteRef.current.bracketed && key.return) {
628
+ pasteRef.current.bracketedBuffer += '\n'
629
+ return
630
+ }
631
+
632
+ // Ink 5 incorrectly marks a plain carriage-return as Shift because "\r" is
633
+ // unchanged by toUpperCase(). Detect enhanced-keyboard Shift+Enter from its
634
+ // raw sequence instead of trusting key.shift on an ordinary Enter event.
635
+ if (isEnhancedNewlineInput(input)) { insertText('\n'); return }
636
+
637
+ // Terminals that support bracketed paste wrap the payload in ESC[200~ / ESC[201~.
638
+ // Ink's parser strips a leading ESC, so accept both the raw and stripped marker forms.
639
+ if (handleBracketedPasteInput(input)) return
640
+
641
+ const current = valueRef.current
642
+ const at = clampCursor(current, cursorRef.current)
643
+
644
+ // A few terminals (notably ConPTY and some SSH/tmux combinations) do not expose
645
+ // bracketed paste. They deliver a paste as fast text chunks separated by Enter events.
646
+ // Track that short burst so those Enters become newlines instead of submitting each line.
647
+ if (!key.return && input && !key.ctrl && !key.meta && /[\r\n]/.test(input)) {
648
+ insertText(input)
649
+ pasteRef.current.burstActive = false
650
+ pasteRef.current.lastChunkAt = now
651
+ pasteRef.current.lastChunkLength = 0
652
+ return
653
+ }
654
+
440
655
  if (popupOpen) {
441
656
  if (key.upArrow) { setPopupIdx(i => (i <= 0 ? filtered.length - 1 : i - 1)); return }
442
657
  if (key.downArrow) { setPopupIdx(i => (i + 1) % filtered.length); return }
@@ -448,9 +663,22 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
448
663
  }
449
664
 
450
665
  if (key.return) {
451
- if (value.trim()) {
452
- historyRef.current.push(value)
453
- onSubmit(value)
666
+ const state = pasteRef.current
667
+ const inPasteBurst = state.burstActive || (
668
+ state.lastChunkLength > 1 && now - state.lastChunkAt <= pasteBurstWindowMs()
669
+ )
670
+ if (inPasteBurst && !key.ctrl && !key.meta) {
671
+ insertText('\n')
672
+ state.burstActive = true
673
+ state.lastChunkAt = now
674
+ state.lastChunkLength = 0
675
+ schedulePasteBurstReset()
676
+ return
677
+ }
678
+ const submitted = valueRef.current
679
+ if (submitted.trim()) {
680
+ historyRef.current.push(submitted)
681
+ onSubmit(submitted)
454
682
  edit('', 0)
455
683
  setHistIdx(null)
456
684
  }
@@ -459,14 +687,24 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
459
687
  if (key.ctrl && input === 'c') { typing ? void onStop() : onQuit(); return }
460
688
  // Ink reports the terminal Backspace key (\x7f) as `key.delete`; handle both
461
689
  // 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)
690
+ if (key.backspace || key.delete || (key.ctrl && (input === 'h' || input === 'w'))) {
691
+ if (at > 0) {
692
+ if (key.ctrl && input === 'w') {
693
+ const before = current.slice(0, at)
694
+ const match = before.match(/\S+\s*$/)
695
+ const cut = match ? match[0].length : 0
696
+ edit(current.slice(0, at - cut) + current.slice(at), at - cut)
697
+ } else {
698
+ const previous = previousCursorBoundary(current, at)
699
+ edit(current.slice(0, previous) + current.slice(at), previous)
700
+ }
701
+ }
464
702
  return
465
703
  }
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 }
704
+ if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
705
+ if (key.rightArrow) { moveCursor(nextCursorBoundary(current, at)); return }
468
706
 
469
- const onFirstLine = value.slice(0, cursor).indexOf('\n') === -1
707
+ const onFirstLine = current.slice(0, at).indexOf('\n') === -1
470
708
  if (key.upArrow && onFirstLine) {
471
709
  const history = historyRef.current
472
710
  if (history.length) {
@@ -476,6 +714,7 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
476
714
  }
477
715
  return
478
716
  }
717
+ if (key.upArrow) { moveVertical(-1); return }
479
718
  if (key.downArrow && histIdx !== null) {
480
719
  const history = historyRef.current
481
720
  const next = histIdx + 1
@@ -483,22 +722,37 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
483
722
  else { setHistIdx(next); edit(history[next], history[next].length) }
484
723
  return
485
724
  }
486
- if (key.ctrl && input === 'a') { setCursor(0); return }
487
- if (key.ctrl && input === 'e') { setCursor(value.length); return }
725
+ if (key.downArrow) { moveVertical(1); return }
726
+ if (key.ctrl && input === 'a') { moveCursor(0); return }
727
+ if (key.ctrl && input === 'e') { moveCursor(current.length); return }
488
728
  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 }
729
+ if (key.ctrl && input === 'k') { edit(current.slice(0, at), at); return }
730
+ if (key.ctrl && input === 'j') { insertText('\n'); return }
491
731
  if (key.ctrl || key.meta || escape || !input) return
492
- edit(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length)
732
+
733
+ const chunkAt = pasteRef.current
734
+ const continuesBurst = chunkAt.lastChunkAt > 0 && now - chunkAt.lastChunkAt <= pasteBurstWindowMs()
735
+ chunkAt.consecutivePlain = continuesBurst ? chunkAt.consecutivePlain + 1 : 1
736
+ if (input.length > 1 || chunkAt.consecutivePlain >= 3) {
737
+ chunkAt.burstActive = true
738
+ schedulePasteBurstReset()
739
+ }
740
+ insertText(input)
741
+ chunkAt.lastChunkAt = now
742
+ chunkAt.lastChunkLength = input.length
493
743
  })
494
744
 
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)
745
+ const composerWidth = Math.max(12, (stdout.columns ?? 80) - 9)
746
+ const wrapped = wrapComposerLines(value, composerWidth)
747
+ const visualCursor = findComposerCursorLine(wrapped, clampCursor(value, cursor))
748
+ const maxRows = Math.max(3, Math.min(10, Math.floor((stdout.rows ?? 24) * 0.42)))
749
+ const firstVisible = Math.max(0, Math.min(visualCursor - maxRows + 1, visualCursor))
750
+ const visible = wrapped.slice(firstVisible, firstVisible + maxRows)
751
+ const renderedRows = 4 + visible.length + (popupOpen ? filtered.length + 1 : 0)
752
+
753
+ useEffect(() => {
754
+ onHeightChange?.(renderedRows)
755
+ }, [onHeightChange, renderedRows])
502
756
 
503
757
  return (
504
758
  <Box flexDirection="column" marginTop={1}>
@@ -515,28 +769,141 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
515
769
  ))}
516
770
  </Box>
517
771
  ) : 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>
772
+ <Box
773
+ flexDirection="column"
774
+ borderStyle="round"
775
+ borderColor={typing ? 'yellow' : 'gray'}
776
+ borderDimColor={!typing}
777
+ paddingX={1}
778
+ >
779
+ <Box flexDirection="column" height={Math.min(maxRows, wrapped.length)} overflow="hidden">
780
+ {visible.map((line, index) => {
781
+ const realIndex = firstVisible + index
782
+ const isCursorLine = realIndex === visualCursor
783
+ const c = isCursorLine ? clampCursor(value, cursor) - line.start : -1
784
+ const before = isCursorLine ? line.text.slice(0, Math.max(0, c)) : line.text
785
+ const atCursor = isCursorLine ? line.text.slice(Math.max(0, c), Math.max(0, c) + 1) : ''
786
+ const after = isCursorLine ? line.text.slice(Math.max(0, c) + 1) : ''
787
+ return (
788
+ <Text key={`${line.start}-${realIndex}`}>
789
+ {index === 0 ? <Text bold>{firstVisible > 0 ? '… ' : '› '}</Text> : <Text>{' '}</Text>}
790
+ {isCursorLine
791
+ ? <>{before}<Text backgroundColor="white" color="black">{atCursor || ' '}</Text>{after}</>
792
+ : line.text}
793
+ {value === '' && index === 0 ? <Text dimColor> 输入问题或 / 命令</Text> : null}
794
+ </Text>
795
+ )
532
796
  })}
533
- {value === '' ? <Text dimColor> 输入问题或 / 命令</Text> : null}
534
- </Text>
797
+ </Box>
798
+ <Box justifyContent="space-between">
799
+ <Text dimColor>{(stdout.columns ?? 80) >= 58 ? 'Enter 发送 · Shift+Enter / Ctrl+J 换行' : 'Enter 发送 · Ctrl+J 换行'}</Text>
800
+ <Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
801
+ </Box>
535
802
  </Box>
536
803
  </Box>
537
804
  )
538
805
  }
539
806
 
807
+ interface ComposerPasteState {
808
+ bracketed: boolean
809
+ bracketedBuffer: string
810
+ burstActive: boolean
811
+ consecutivePlain: number
812
+ lastChunkAt: number
813
+ lastChunkLength: number
814
+ timer: ReturnType<typeof setTimeout> | null
815
+ }
816
+
817
+ function pasteBurstWindowMs(): number {
818
+ return process.platform === 'win32' ? 60 : 20
819
+ }
820
+
821
+ function normalizeComposerPaste(text: string): string {
822
+ return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
823
+ }
824
+
825
+ function isEnhancedNewlineInput(input: string): boolean {
826
+ return /^\[(?:13|27);2(?:u|~)$/.test(input) || input === '\x1b\r'
827
+ }
828
+
829
+ function findPasteMarker(input: string, code: '200' | '201', from = 0): number {
830
+ const raw = `\x1b[${code}~`
831
+ const stripped = `[${code}~`
832
+ const rawAt = input.indexOf(raw, from)
833
+ const strippedAt = input.indexOf(stripped, from)
834
+ if (rawAt < 0) return strippedAt
835
+ if (strippedAt < 0) return rawAt
836
+ return Math.min(rawAt, strippedAt)
837
+ }
838
+
839
+ function pasteMarkerLength(input: string, at: number, code: '200' | '201'): number {
840
+ return input.startsWith(`\x1b[${code}~`, at) ? 6 : 5
841
+ }
842
+
843
+ function clampCursor(text: string, cursor: number): number {
844
+ let at = Math.max(0, Math.min(text.length, cursor))
845
+ while (at > 0 && at < text.length && /[\uDC00-\uDFFF]/.test(text[at])) at--
846
+ return at
847
+ }
848
+
849
+ function previousCursorBoundary(text: string, cursor: number): number {
850
+ const at = clampCursor(text, cursor)
851
+ if (at <= 0) return 0
852
+ const code = text.charCodeAt(at - 1)
853
+ return at - (code >= 0xDC00 && code <= 0xDFFF ? 2 : 1)
854
+ }
855
+
856
+ function nextCursorBoundary(text: string, cursor: number): number {
857
+ const at = clampCursor(text, cursor)
858
+ if (at >= text.length) return text.length
859
+ const code = text.charCodeAt(at)
860
+ return at + (code >= 0xD800 && code <= 0xDBFF ? 2 : 1)
861
+ }
862
+
863
+ interface ComposerLine { text: string; start: number; end: number }
864
+
865
+ function wrapComposerLines(text: string, width: number): ComposerLine[] {
866
+ if (!text) return [{ text: '', start: 0, end: 0 }]
867
+ const result: ComposerLine[] = []
868
+ let lineStart = 0
869
+ let lineText = ''
870
+ let lineWidth = 0
871
+ for (let i = 0; i < text.length;) {
872
+ const ch = text[i]
873
+ if (ch === '\n') {
874
+ result.push({ text: lineText, start: lineStart, end: i })
875
+ lineText = ''
876
+ lineWidth = 0
877
+ lineStart = i + 1
878
+ i++
879
+ continue
880
+ }
881
+ const next = nextCursorBoundary(text, i)
882
+ const piece = text.slice(i, next)
883
+ const pieceWidth = Math.max(1, displayWidth(piece))
884
+ if (lineText && lineWidth + pieceWidth > width) {
885
+ result.push({ text: lineText, start: lineStart, end: i })
886
+ lineText = ''
887
+ lineWidth = 0
888
+ lineStart = i
889
+ }
890
+ lineText += piece
891
+ lineWidth += pieceWidth
892
+ i = next
893
+ }
894
+ result.push({ text: lineText, start: lineStart, end: text.length })
895
+ return result
896
+ }
897
+
898
+ function findComposerCursorLine(lines: ComposerLine[], cursor: number): number {
899
+ const at = Math.max(0, cursor)
900
+ for (let i = 0; i < lines.length; i++) {
901
+ const line = lines[i]
902
+ if (at < line.end || (at === line.end && (i === lines.length - 1 || lines[i + 1].start > at))) return i
903
+ }
904
+ return Math.max(0, lines.length - 1)
905
+ }
906
+
540
907
  function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDisplay }: {
541
908
  ready: ReadyState
542
909
  sessionId: string | null
@@ -608,8 +975,9 @@ function compactPath(path: string): string {
608
975
  }
609
976
 
610
977
  function truncateDisplay(value: string, maxLength: number): string {
611
- if (value.length <= maxLength) return value
612
- return maxLength <= 1 ? '…' : `${value.slice(0, maxLength - 1)}…`
978
+ const limit = Math.max(1, Math.floor(maxLength))
979
+ if (value.length <= limit) return value
980
+ return limit <= 1 ? '…' : `${value.slice(0, limit - 1)}…`
613
981
  }
614
982
 
615
983
  function buildWebUrl(server: string, webUserId: string, ready: ReadyState, sessionId: string | null): string {
@@ -658,5 +1026,59 @@ function isWideCodepoint(code: number): boolean {
658
1026
  )
659
1027
  }
660
1028
 
661
- // (fitTranscript / blockRows / wrappedRows 视窗裁剪 + in-app 翻页逻辑已移除:
662
- // transcript 现由 <Static> 累积进终端 scrollback, 历史靠终端自身滚动.)
1029
+ function wrappedRows(text: string, width: number): number {
1030
+ const safeWidth = Math.max(1, width)
1031
+ return text.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(displayWidth(line) / safeWidth)), 0)
1032
+ }
1033
+
1034
+ function entryRows(entry: AnyEntry, columns: number): number {
1035
+ const width = Math.max(8, columns - 4)
1036
+ return Math.max(1, viewsForEntry(entry).reduce((sum, view) => {
1037
+ switch (view.kind) {
1038
+ case 'skip': return sum
1039
+ case 'user': return sum + 1 + wrappedRows(view.text, width - 2)
1040
+ case 'assistant': {
1041
+ const rows = renderMarkdownLines(view.text).reduce((total, line) => {
1042
+ return total + (line.code ? 1 : wrappedRows(line.text || ' ', width - 2))
1043
+ }, 0)
1044
+ return sum + 1 + rows
1045
+ }
1046
+ case 'tool_call': return sum + 2 + (view.result ? 1 : 0)
1047
+ case 'tool_result': return sum + headTailLines(view.text, width - 4, 5).length
1048
+ case 'code_edit':
1049
+ return sum + 2 + wrappedRows(view.filePath || '(未指定文件)', width - 4)
1050
+ + wrappedRows(view.oldString, width - 4) + wrappedRows(view.newString, width - 4)
1051
+ case 'write_file':
1052
+ return sum + 2 + wrappedRows(view.filePath || '(未指定文件)', width - 4)
1053
+ + wrappedRows(view.content, width - 4)
1054
+ case 'reasoning': return sum + 1 + clampLines(view.text, width - 4, 2).length
1055
+ case 'system': return sum + 1
1056
+ case 'error': return sum + 1 + wrappedRows(view.text, width - 2)
1057
+ default: return sum
1058
+ }
1059
+ }, 0))
1060
+ }
1061
+
1062
+ function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
1063
+ entries: AnyEntry[]
1064
+ hiddenOlder: number
1065
+ hiddenRecent: number
1066
+ startIndex: number
1067
+ } {
1068
+ const tail = Math.max(0, entries.length - scrollBack)
1069
+ const available = tail === 0 ? [] : entries.slice(0, tail)
1070
+ let rows = 0
1071
+ let first = available.length
1072
+ for (let index = available.length - 1; index >= 0; index--) {
1073
+ const nextRows = entryRows(available[index], columns)
1074
+ if (first < available.length && rows + nextRows > rowBudget) break
1075
+ rows += nextRows
1076
+ first = index
1077
+ }
1078
+ return {
1079
+ entries: available.slice(first),
1080
+ hiddenOlder: first,
1081
+ hiddenRecent: entries.length - tail,
1082
+ startIndex: first,
1083
+ }
1084
+ }
@@ -3,13 +3,96 @@
3
3
  * Select (single-choice list + multi-choice with checkboxes), and a Spinner.
4
4
  */
5
5
  import React, { useEffect, useRef, useState } from 'react'
6
- import { Box, Text, useInput, useStdout } from 'ink'
6
+ import { Box, Text, useInput, useStdout, useStdin } from 'ink'
7
7
 
8
8
  /** Windows Terminal/ConPTY may expose Esc as a named key, a raw byte, or Ctrl+[. */
9
9
  export function isEscapeKeypress(input: string, key: { escape?: boolean; ctrl?: boolean }): boolean {
10
10
  return key.escape === true || input === '\x1b' || (key.ctrl === true && input === '[')
11
11
  }
12
12
 
13
+ // ─── Mouse wheel ─────────────────────────────────────────────────────────────
14
+ // Terminals report wheel events only after DECSET 1000 (button-event) + 1006
15
+ // (SGR coordinates) are enabled. A wheel tick arrives as a mouse sequence:
16
+ // wheel up → ESC [ < 64 ; x ; y M (SGR, the modern encoding)
17
+ // wheel down → ESC [ < 65 ; x ; y M
18
+ // Legacy X10 (no SGR support) reports ESC [ M Cb Cx Cy with Cb = button + 32,
19
+ // so wheel up is 0x60 (`) and wheel down is 0x61 (a). There is no release event
20
+ // for the wheel in either form. Button 64/65 map to a delta of +1/-1 so the
21
+ // transcript pager can scroll back/forward by a fixed step.
22
+ const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g
23
+ const LEGACY_MOUSE_RE = /\x1b\[M([\s\S]{3})/g
24
+
25
+ /**
26
+ * True when `input` (a chunk Ink forwarded to useInput handlers) begins with a
27
+ * mouse event. Ink strips a leading ESC before passing `input`, so both the raw
28
+ * and stripped forms are accepted. Guards must be added to any handler that
29
+ * would otherwise treat a mouse event as typed text.
30
+ */
31
+ export function isMouseInput(input: string): boolean {
32
+ return /^\x1b?\[<\d+;\d+;\d+[Mm]/.test(input) || /^\x1b?\[M/.test(input)
33
+ }
34
+
35
+ /** Extract the wheel delta from a chunk: +1 wheel-up, -1 wheel-down, else 0. */
36
+ export function mouseWheelDelta(input: string): number {
37
+ SGR_MOUSE_RE.lastIndex = 0
38
+ let delta = 0
39
+ let m: RegExpExecArray | null
40
+ while ((m = SGR_MOUSE_RE.exec(input)) !== null) {
41
+ const btn = Number(m[1])
42
+ if (btn === 64) delta++
43
+ else if (btn === 65) delta--
44
+ }
45
+ LEGACY_MOUSE_RE.lastIndex = 0
46
+ let lm: RegExpExecArray | null
47
+ while ((lm = LEGACY_MOUSE_RE.exec(input)) !== null) {
48
+ const btn = lm[1].charCodeAt(0) - 32 // X10 adds a 32 offset to the button
49
+ if (btn === 64) delta++
50
+ else if (btn === 65) delta--
51
+ }
52
+ return delta
53
+ }
54
+
55
+ /**
56
+ * Enables terminal mouse tracking for the lifetime of the calling component and
57
+ * forwards wheel deltas to `onWheel`. Mouse events reach the rest of Ink as raw
58
+ * input chunks, so any text-inserting useInput handler must guard with
59
+ * `isMouseInput(input)`.
60
+ *
61
+ * The DECSET enable/disable sequences are only written when stdout is a TTY
62
+ * (writing them into a pipe would litter the output). The emitter listener is
63
+ * attached unconditionally so the harness can simulate wheel events.
64
+ */
65
+ export function useMouseWheel(onWheel: (delta: number) => void): void {
66
+ const { internal_eventEmitter } = useStdin()
67
+ const { stdout } = useStdout()
68
+ const cbRef = useRef(onWheel)
69
+ cbRef.current = onWheel
70
+
71
+ useEffect(() => {
72
+ if (!internal_eventEmitter) return
73
+ const isTTY = Boolean(stdout.isTTY)
74
+ if (isTTY) stdout.write('\x1b[?1000h\x1b[?1006h')
75
+ let buf = ''
76
+ const handler = (chunk: unknown) => {
77
+ // A single read() chunk may carry several wheel ticks (fast scrolling) and
78
+ // an SGR sequence may be split across chunks, so accumulate and re-scan.
79
+ buf += String(chunk)
80
+ const delta = mouseWheelDelta(buf)
81
+ // Drop the fully-matched sequences, keeping any trailing partial escape
82
+ // prefix so a split sequence still matches on the next chunk.
83
+ buf = buf.replace(SGR_MOUSE_RE, '').replace(LEGACY_MOUSE_RE, '')
84
+ const esc = buf.lastIndexOf('\x1b')
85
+ buf = esc >= 0 ? buf.slice(esc) : ''
86
+ if (delta !== 0) cbRef.current(delta)
87
+ }
88
+ internal_eventEmitter.on('input', handler)
89
+ return () => {
90
+ internal_eventEmitter.off('input', handler)
91
+ if (isTTY) stdout.write('\x1b[?1000l\x1b[?1006l')
92
+ }
93
+ }, [internal_eventEmitter, stdout])
94
+ }
95
+
13
96
  // ─── TextInput ───────────────────────────────────────────────────────────────
14
97
  export interface TextInputProps {
15
98
  value: string
@@ -48,6 +131,7 @@ export function TextInput(props: TextInputProps) {
48
131
  }
49
132
 
50
133
  useInput((input, key) => {
134
+ if (isMouseInput(input)) return
51
135
  if (key.return) { props.onSubmit?.(); return }
52
136
  if (key.upArrow) { props.onArrowUp?.(); return }
53
137
  if (key.downArrow) { props.onArrowDown?.(); return }
@@ -172,6 +256,7 @@ export function Select(props: SelectProps) {
172
256
 
173
257
  useInput((input, key) => {
174
258
  if (!items.length) return
259
+ if (isMouseInput(input)) return
175
260
  if (key.upArrow) { setActive(a => (a - 1 + items.length) % items.length); return }
176
261
  if (key.downArrow) { setActive(a => (a + 1) % items.length); return }
177
262
  if (mode === 'single') {
@@ -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