@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 +1 -1
- package/src/aimux.ts +86 -14
- package/src/components/Chat.tsx +514 -92
- package/src/components/primitives.tsx +86 -1
- 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'
|
|
@@ -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 "
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
156
|
-
rows: Math.max(
|
|
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(
|
|
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(
|
|
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
|
-
? `•
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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 (
|
|
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) {
|
|
467
|
-
if (key.rightArrow) {
|
|
704
|
+
if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
|
|
705
|
+
if (key.rightArrow) { moveCursor(nextCursorBoundary(current, at)); return }
|
|
468
706
|
|
|
469
|
-
const onFirstLine =
|
|
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.
|
|
487
|
-
if (key.ctrl && input === '
|
|
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(
|
|
490
|
-
if (key.ctrl && input === 'j') {
|
|
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
|
-
|
|
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
|
|
496
|
-
const
|
|
497
|
-
const
|
|
498
|
-
const
|
|
499
|
-
const
|
|
500
|
-
const
|
|
501
|
-
const
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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') {
|
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
|