@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 +1 -1
- package/src/aimux.ts +86 -14
- package/src/components/Chat.tsx +492 -91
- package/src/hooks/useChat.ts +31 -3
package/package.json
CHANGED
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
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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({
|
package/src/components/Chat.tsx
CHANGED
|
@@ -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,
|
|
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 "
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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(
|
|
156
|
-
rows: Math.max(
|
|
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(
|
|
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(
|
|
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
|
-
? `•
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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 (
|
|
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) {
|
|
467
|
-
if (key.rightArrow) {
|
|
683
|
+
if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
|
|
684
|
+
if (key.rightArrow) { moveCursor(nextCursorBoundary(current, at)); return }
|
|
468
685
|
|
|
469
|
-
const onFirstLine =
|
|
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.
|
|
487
|
-
if (key.ctrl && input === '
|
|
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(
|
|
490
|
-
if (key.ctrl && input === 'j') {
|
|
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
|
-
|
|
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
|
|
496
|
-
const
|
|
497
|
-
const
|
|
498
|
-
const
|
|
499
|
-
const
|
|
500
|
-
const
|
|
501
|
-
const
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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
|
+
}
|
package/src/hooks/useChat.ts
CHANGED
|
@@ -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
|
-
//
|
|
359
|
-
|
|
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
|