@mobius-os/mobius 0.2.9 → 0.3.1
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 +2 -1
- package/src/aimux.ts +139 -18
- package/src/components/AimuxStatus.tsx +9 -3
- package/src/components/Chat.tsx +72 -10
- package/src/lib/entry-view.ts +51 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mobius-os/mobius",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"chalk": "^5.3.0",
|
|
20
20
|
"cli-highlight": "2.1.11",
|
|
21
|
+
"extract-zip": "^2.0.1",
|
|
21
22
|
"ink": "5.2.0",
|
|
22
23
|
"marked": "12.0.2",
|
|
23
24
|
"react": "18.3.1",
|
package/src/aimux.ts
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* currently authenticated Mobius server. Nothing is started before login.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
|
9
|
-
import { promises as fs, existsSync } from 'node:fs'
|
|
9
|
+
import { promises as fs, existsSync, createWriteStream } from 'node:fs'
|
|
10
10
|
import os from 'node:os'
|
|
11
11
|
import path from 'node:path'
|
|
12
|
+
import { Readable } from 'node:stream'
|
|
13
|
+
import extract from 'extract-zip'
|
|
12
14
|
import { mobiusHome } from './config.js'
|
|
13
15
|
|
|
14
16
|
export type AimuxState = 'starting' | 'connected' | 'failed' | 'stopped' | 'disabled'
|
|
@@ -22,6 +24,11 @@ export interface AimuxStatus {
|
|
|
22
24
|
}
|
|
23
25
|
export interface InstallProgress { phase: 'python' | 'venv' | 'install' | 'ready'; detail?: string }
|
|
24
26
|
|
|
27
|
+
/** aimux 的调用方式:venv 直接执行,或用内置 python 跑 `-m aimux`(Plan B 兜底)。 */
|
|
28
|
+
export type AimuxLauncher =
|
|
29
|
+
| { kind: 'exe'; path: string }
|
|
30
|
+
| { kind: 'module'; python: string }
|
|
31
|
+
|
|
25
32
|
const AIMUX_PACKAGE = 'aimux'
|
|
26
33
|
const WIN = process.platform === 'win32'
|
|
27
34
|
const venvDir = () => path.join(mobiusHome(), 'aimux-venv')
|
|
@@ -76,21 +83,125 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
|
|
|
76
83
|
return findPython() ?? installPython(onProgress)
|
|
77
84
|
}
|
|
78
85
|
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
// ── Plan B: 内置 python+aimux 运行时(本地 venv/pip 失败时的离线兜底)─────────
|
|
87
|
+
// 一个 zip 内含完整的 python-build-standalone(自带 ensurepip+pip)+ 预装 aimux;
|
|
88
|
+
// 解压到 ~/.mobius/python-bundle/ 后用 `<python> -m aimux` 运行,彻底绕开宿主机
|
|
89
|
+
// 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
|
|
90
|
+
// 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
|
|
91
|
+
const BUNDLE_VER = '1'
|
|
92
|
+
const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
|
|
93
|
+
const bundlePython = () => WIN
|
|
94
|
+
? path.join(bundleDir(), 'python', 'python.exe')
|
|
95
|
+
: path.join(bundleDir(), 'python', 'bin', 'python3')
|
|
96
|
+
|
|
97
|
+
/** 当前平台对应的内置运行时包名;mac-arm64 走 mac-x64(Rosetta 2)。 */
|
|
98
|
+
export function bundleArch(): string | null {
|
|
99
|
+
const { platform, arch } = process
|
|
100
|
+
if (platform === 'linux' && arch === 'x64') return 'linux-x64'
|
|
101
|
+
if (platform === 'win32' && arch === 'x64') return 'win-x64'
|
|
102
|
+
if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) return 'mac-x64'
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** CDN 基址可用 MOBIUS_TUI_PYTHON_BUNDLE_URL 覆盖;文件名固定为 mobius-python-<arch>-v<N>.zip。 */
|
|
107
|
+
export const bundleBaseUrl = () => (process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL || 'https://serve.nutshellai.cn/publish/auto/mobius-tui').replace(/\/$/, '')
|
|
108
|
+
export const bundleUrl = (arch: string) => `${bundleBaseUrl()}/mobius-python-${arch}-v${BUNDLE_VER}.zip`
|
|
109
|
+
|
|
110
|
+
function bundleReady(): boolean {
|
|
111
|
+
return existsSync(bundlePython()) && spawnSync(bundlePython(), ['-c', 'import aimux'], { stdio: 'ignore', windowsHide: true }).status === 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function downloadBundle(arch: string, onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; zipPath?: string }> {
|
|
115
|
+
const url = bundleUrl(arch)
|
|
116
|
+
const zipPath = path.join(mobiusHome(), `python-bundle-v${BUNDLE_VER}.zip.tmp`)
|
|
117
|
+
await fs.mkdir(path.dirname(zipPath), { recursive: true }) // 首次安装 ~/.mobius 可能尚未创建
|
|
118
|
+
let res: Response
|
|
119
|
+
try { res = await fetch(url) } catch (e: any) { return { ok: false, error: `下载失败: ${e?.message ?? e}` } }
|
|
120
|
+
if (!res.ok) return { ok: false, error: `下载失败: HTTP ${res.status} (${url})` }
|
|
121
|
+
const total = Number(res.headers.get('content-length') || 0)
|
|
122
|
+
const ws = createWriteStream(zipPath)
|
|
123
|
+
let got = 0, last = 0
|
|
124
|
+
try {
|
|
125
|
+
const stream = Readable.fromWeb(res.body as any)
|
|
126
|
+
for await (const chunk of stream) {
|
|
127
|
+
ws.write(chunk as Buffer)
|
|
128
|
+
got += (chunk as Buffer).length
|
|
129
|
+
if (total && got - last > total * 0.03) { last = got; onProgress?.({ phase: 'install', detail: `下载内置运行时 ${Math.round((got / total) * 100)}%` }) }
|
|
130
|
+
}
|
|
131
|
+
if (!total && got) onProgress?.({ phase: 'install', detail: `下载内置运行时 ${(got / 1048576).toFixed(0)}MB` })
|
|
132
|
+
await new Promise<void>((resolve, reject) => { ws.end(() => resolve()); ws.on('error', reject) })
|
|
133
|
+
} catch (e: any) {
|
|
134
|
+
try { ws.destroy() } catch {}
|
|
135
|
+
try { await fs.unlink(zipPath) } catch {}
|
|
136
|
+
return { ok: false, error: `下载失败: ${e?.message ?? e}` }
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, zipPath }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function extractBundle(zipPath: string): Promise<{ ok: boolean; error?: string }> {
|
|
142
|
+
const staging = path.join(mobiusHome(), 'python-bundle.new')
|
|
143
|
+
const finalDir = bundleDir()
|
|
144
|
+
const stagingPython = WIN ? path.join(staging, 'python', 'python.exe') : path.join(staging, 'python', 'bin', 'python3')
|
|
145
|
+
await fs.rm(staging, { recursive: true, force: true }).catch(() => {})
|
|
146
|
+
await fs.mkdir(staging, { recursive: true })
|
|
147
|
+
try { await extract(zipPath, { dir: staging, defaultDirMode: 0o755, defaultFileMode: 0o644 }) }
|
|
148
|
+
catch (e: any) { await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); return { ok: false, error: `解压失败: ${e?.message ?? e}` } }
|
|
149
|
+
if (!WIN) try { await fs.chmod(stagingPython, 0o755) } catch {} // 保险:确保可执行位(extract-zip 通常已还原)
|
|
150
|
+
await fs.rm(finalDir, { recursive: true, force: true }).catch(() => {})
|
|
151
|
+
await fs.rename(staging, finalDir)
|
|
152
|
+
return { ok: true }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function ensureFromBundle(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; launcher?: AimuxLauncher }> {
|
|
156
|
+
if (bundleReady()) return { ok: true, launcher: { kind: 'module', python: bundlePython() } }
|
|
157
|
+
const arch = bundleArch()
|
|
158
|
+
if (!arch) return { ok: false, error: `当前平台无内置运行时 (platform=${process.platform} arch=${process.arch})` }
|
|
159
|
+
onProgress?.({ phase: 'install', detail: `下载内置运行时 (${arch})…` })
|
|
160
|
+
const dl = await downloadBundle(arch, onProgress)
|
|
161
|
+
if (!dl.ok || !dl.zipPath) return { ok: false, error: dl.error }
|
|
162
|
+
onProgress?.({ phase: 'install', detail: '解压内置运行时…' })
|
|
163
|
+
const ex = await extractBundle(dl.zipPath)
|
|
164
|
+
try { await fs.unlink(dl.zipPath) } catch {}
|
|
165
|
+
if (!ex.ok) return { ok: false, error: ex.error }
|
|
166
|
+
if (!bundleReady()) return { ok: false, error: '内置运行时解压后仍无法 import aimux' }
|
|
167
|
+
return { ok: true, launcher: { kind: 'module', python: bundlePython() } }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 按 launcher 把 aimux 参数变成实际 spawn。 */
|
|
171
|
+
export function spawnLauncher(launcher: AimuxLauncher, args: string[]): ChildProcess {
|
|
172
|
+
return launcher.kind === 'exe'
|
|
173
|
+
? spawn(launcher.path, args, { windowsHide: true })
|
|
174
|
+
: spawn(launcher.python, ['-m', 'aimux', ...args], { windowsHide: true })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** test-only 导出: 暴露内部 downloadBundle 以便单测 mock fetch 验证流式下载+进度。 */
|
|
178
|
+
export const downloadBundleForTest = downloadBundle
|
|
179
|
+
|
|
180
|
+
export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; launcher?: AimuxLauncher }> {
|
|
181
|
+
// Fast-path:venv 里已有 aimux 可执行 → 直接用。
|
|
182
|
+
if (existsSync(aimuxExe()) && existsSync(venvPython())) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: { kind: 'exe', path: aimuxExe() } } }
|
|
81
183
|
const py = await pythonForAimux(onProgress)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
184
|
+
let venvError = '未找到 Python。请先安装 Python 3.10+(或安装 uv 后重试)。'
|
|
185
|
+
if (py) {
|
|
186
|
+
onProgress?.({ phase: 'venv', detail: `创建 Python 虚拟环境(${py})…` })
|
|
187
|
+
let r = await run(py, ['-m', 'venv', venvDir()])
|
|
188
|
+
if (r.code !== 0 && py === 'py') r = await run(py, ['-3', '-m', 'venv', venvDir()])
|
|
189
|
+
if (r.code === 0) {
|
|
190
|
+
onProgress?.({ phase: 'install', detail: `下载并安装 ${AIMUX_PACKAGE}…` })
|
|
191
|
+
r = await run(venvPython(), ['-m', 'pip', 'install', '--no-input', '--disable-pip-version-check', AIMUX_PACKAGE], line => {
|
|
192
|
+
if (/downloading|collecting|installing|using cached|%\s*\d|━|─/i.test(line)) onProgress?.({ phase: 'install', detail: line.slice(0, 120) })
|
|
193
|
+
})
|
|
194
|
+
if (r.code === 0 && existsSync(aimuxExe())) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: { kind: 'exe', path: aimuxExe() } } }
|
|
195
|
+
venvError = r.code === 0 ? `aimux 可执行未生成: ${aimuxExe()}` : `pip install 失败: ${r.stderr || r.stdout}`
|
|
196
|
+
} else {
|
|
197
|
+
venvError = `venv 创建失败: ${r.stderr || r.stdout}`
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// ── Plan B 兜底:本地 python/venv 不可用 → 下载内置 python+aimux 运行时 ──
|
|
201
|
+
onProgress?.({ phase: 'install', detail: '本地 Python 不可用,改用内置运行时…' })
|
|
202
|
+
const bundle = await ensureFromBundle(onProgress)
|
|
203
|
+
if (bundle.ok && bundle.launcher) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: bundle.launcher } }
|
|
204
|
+
return { ok: false, error: `${venvError};内置运行时也失败: ${bundle.error}` }
|
|
94
205
|
}
|
|
95
206
|
|
|
96
207
|
export function tuiAimuxIdentifier(): string {
|
|
@@ -155,8 +266,10 @@ export class AimuxSupervisor {
|
|
|
155
266
|
)
|
|
156
267
|
this.child = child
|
|
157
268
|
this.startHeartbeat()
|
|
269
|
+
let tail = '' // 缓存 aimux 最近输出, 进程异常退出时带进状态行, 便于诊断(code=1 不再是黑盒)
|
|
158
270
|
const classify = (buf: Buffer) => {
|
|
159
271
|
const text = buf.toString('utf8')
|
|
272
|
+
tail = (tail + text).slice(-4000)
|
|
160
273
|
if (!this.bridgeConnected && /connected|registered|event stream|heartbeat|sse/i.test(text)) {
|
|
161
274
|
onStatus({ state: 'starting', phase: 'heartbeat', detail: 'AIMUX 已启动,等待 bridge 心跳确认…', identifier })
|
|
162
275
|
} else if (/connection (refused|reset|closed|error)|failed to connect|unauthorized|forbidden|token.*invalid/i.test(text)) {
|
|
@@ -170,7 +283,10 @@ export class AimuxSupervisor {
|
|
|
170
283
|
this.child = null
|
|
171
284
|
this.stopHeartbeat()
|
|
172
285
|
if (this.stopping) { onStatus({ state: 'stopped', phase: 'idle', detail: 'AIMUX 已停止', identifier }); return }
|
|
173
|
-
|
|
286
|
+
const reason = code !== 0 && tail.trim()
|
|
287
|
+
? `AIMUX 进程退出(code=${code}): ${tail.trim().split(/[\r\n]+/).filter(Boolean).slice(-3).join(' ⏎ ').slice(-200)}`
|
|
288
|
+
: `AIMUX 进程退出(code=${code})`
|
|
289
|
+
this.scheduleReconnect(reason)
|
|
174
290
|
})
|
|
175
291
|
}
|
|
176
292
|
|
|
@@ -281,8 +397,13 @@ export async function startAimuxConnection(opts: { server: string; token: string
|
|
|
281
397
|
phase: p.phase === 'ready' ? 'connecting' : p.phase,
|
|
282
398
|
detail: p.detail || (p.phase === 'ready' ? 'AIMUX 已就绪,准备连接…' : p.phase),
|
|
283
399
|
}))
|
|
284
|
-
if (!ready.ok) { onStatus({ state: 'failed', phase: 'idle', detail: ready.error }); return }
|
|
285
|
-
|
|
400
|
+
if (!ready.ok || !ready.launcher) { onStatus({ state: 'failed', phase: 'idle', detail: ready.error }); return }
|
|
401
|
+
const identifier = tuiAimuxIdentifier()
|
|
402
|
+
const launcher = ready.launcher
|
|
403
|
+
supervisor = new AimuxSupervisor({
|
|
404
|
+
server: opts.server, token: opts.token, identifier, onStatus,
|
|
405
|
+
spawnProcess: () => spawnLauncher(launcher, ['reverse', 'connect', `${opts.server.replace(/\/$/, '')}/aimux_bridge`, '--identifier', identifier, '--token', opts.token, '--replace']),
|
|
406
|
+
})
|
|
286
407
|
supervisor.start()
|
|
287
408
|
})().finally(() => { installing = null })
|
|
288
409
|
await installing
|
|
@@ -10,15 +10,21 @@ const STYLE: Record<AimuxStatus['state'], { icon: string; color: 'green' | 'yell
|
|
|
10
10
|
disabled: { icon: '○', color: 'gray' },
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// Plain status text (without the leading icon/space), so callers can measure its
|
|
14
|
+
// visible width and lay it out beside other status fragments on one row.
|
|
15
|
+
export function aimuxStatusText(status: AimuxStatus, compact = false): string {
|
|
15
16
|
const phase = status.phase && !['idle', 'connected'].includes(status.phase) ? ` · ${phaseLabel(status.phase)}` : ''
|
|
16
17
|
const detail = status.detail || stateLabel(status.state)
|
|
18
|
+
return `AIMUX${phase} · ${compact ? compactDetail(detail) : detail}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AimuxStatusLine({ status, compact = false }: { status: AimuxStatus; compact?: boolean }) {
|
|
22
|
+
const style = STYLE[status.state]
|
|
17
23
|
return (
|
|
18
24
|
<Box>
|
|
19
25
|
<Text color={style.color}>{style.icon}</Text>
|
|
20
26
|
<Text dimColor={status.state === 'disabled' || status.state === 'stopped'}>
|
|
21
|
-
{' '
|
|
27
|
+
{' ' + aimuxStatusText(status, compact)}
|
|
22
28
|
</Text>
|
|
23
29
|
</Box>
|
|
24
30
|
)
|
package/src/components/Chat.tsx
CHANGED
|
@@ -16,7 +16,7 @@ import { viewsForEntry, dedupeUserEntries, toolLabel, type EntryView } from '../
|
|
|
16
16
|
import type { ReadyState } from './PrepScreen.js'
|
|
17
17
|
import type { AnyEntry } from '../types.js'
|
|
18
18
|
import type { AimuxStatus } from '../aimux.js'
|
|
19
|
-
import { AimuxStatusLine } from './AimuxStatus.js'
|
|
19
|
+
import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
|
|
20
20
|
|
|
21
21
|
interface ChatProps {
|
|
22
22
|
client: MobiusClient
|
|
@@ -549,15 +549,44 @@ function StatusArea({ ready, sessionId, columns, webUrl, aimuxStatus, modelDispl
|
|
|
549
549
|
<Text dimColor>{left}</Text>
|
|
550
550
|
{right ? <Text dimColor>{right}</Text> : null}
|
|
551
551
|
</Box>
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
552
|
+
{/* Merged connectivity row: AIMUX status sits left, the clickable web URL
|
|
553
|
+
sits right and truncates to the remaining width (its OSC 8 link target
|
|
554
|
+
stays full so it stays clickable). This collapses the former separate
|
|
555
|
+
"web · url" and AIMUX rows into one, dropping the status area from
|
|
556
|
+
three rows to two. */}
|
|
557
|
+
<ConnectivityRow aimuxStatus={aimuxStatus} webUrl={webUrl} columns={columns} />
|
|
557
558
|
</Box>
|
|
558
559
|
)
|
|
559
560
|
}
|
|
560
561
|
|
|
562
|
+
// AIMUX status (left) ⟷ clickable web URL (right) on a single row.
|
|
563
|
+
function ConnectivityRow({ aimuxStatus, webUrl, columns }: { aimuxStatus?: AimuxStatus; webUrl: string; columns: number }) {
|
|
564
|
+
const aimuxText = aimuxStatus ? aimuxStatusText(aimuxStatus, true) : ''
|
|
565
|
+
// icon (1) + leading space (1) + status text width
|
|
566
|
+
const aimuxWidth = aimuxText ? 2 + displayWidth(aimuxText) : 0
|
|
567
|
+
// No AIMUX status → web URL keeps the whole row (unchanged from before).
|
|
568
|
+
// Otherwise leave room for the AIMUX block + 'web · ' prefix + a safety gap
|
|
569
|
+
// (the gap also absorbs ambiguous-width chars like box-drawing in the detail).
|
|
570
|
+
const urlBudget = aimuxWidth
|
|
571
|
+
? Math.max(8, columns - 2 - aimuxWidth - WEB_PREFIX.length - 6)
|
|
572
|
+
: undefined
|
|
573
|
+
const web = (
|
|
574
|
+
<Text>
|
|
575
|
+
<Text dimColor>{WEB_PREFIX}</Text>
|
|
576
|
+
<Text color="cyan" underline>{clickableUrl(webUrl, urlBudget)}</Text>
|
|
577
|
+
</Text>
|
|
578
|
+
)
|
|
579
|
+
if (!aimuxStatus) return <Box>{web}</Box>
|
|
580
|
+
return (
|
|
581
|
+
<Box justifyContent="space-between">
|
|
582
|
+
<AimuxStatusLine status={aimuxStatus} compact />
|
|
583
|
+
{web}
|
|
584
|
+
</Box>
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const WEB_PREFIX = 'web · '
|
|
589
|
+
|
|
561
590
|
function compactPath(path: string): string {
|
|
562
591
|
const home = process.env.HOME
|
|
563
592
|
if (!home) return path
|
|
@@ -577,10 +606,43 @@ function buildWebUrl(server: string, webUserId: string, ready: ReadyState, sessi
|
|
|
577
606
|
return sessionId ? `${base}?session=${encodeURIComponent(sessionId)}` : base
|
|
578
607
|
}
|
|
579
608
|
|
|
580
|
-
/** OSC 8 hyperlinks remain readable as plain URLs in terminals without support.
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
609
|
+
/** OSC 8 hyperlinks remain readable as plain URLs in terminals without support.
|
|
610
|
+
* When maxLen is given, only the *visible* text is truncated (the OSC 8 link
|
|
611
|
+
* target keeps the full URL, so it stays clickable on narrow terminals). */
|
|
612
|
+
function clickableUrl(url: string, maxLen?: number): string {
|
|
613
|
+
const display = maxLen != null ? truncateDisplay(url, maxLen) : url
|
|
614
|
+
if (process.env.MOBIUS_TUI_DISABLE_LINKS === '1') return display
|
|
615
|
+
return `\u001B]8;;${url}\u0007${display}\u001B]8;;\u0007`
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Visible-column width (CJK / emoji / fullwidth count as 2; combining marks as
|
|
619
|
+
// 0), used to size the AIMUX status block so the web URL truncates to exactly
|
|
620
|
+
// the remaining width without overflowing the row.
|
|
621
|
+
function displayWidth(str: string): number {
|
|
622
|
+
let w = 0
|
|
623
|
+
for (const ch of str) {
|
|
624
|
+
const code = ch.codePointAt(0) ?? 0
|
|
625
|
+
if (code >= 0x0300 && code <= 0x036F) continue // combining diacriticals: 0 cols
|
|
626
|
+
w += isWideCodepoint(code) ? 2 : 1
|
|
627
|
+
}
|
|
628
|
+
return w
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function isWideCodepoint(code: number): boolean {
|
|
632
|
+
return (
|
|
633
|
+
(code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
|
|
634
|
+
(code >= 0x2E80 && code <= 0x303E) || // CJK radicals / punctuation
|
|
635
|
+
(code >= 0x3041 && code <= 0x33FF) || // Hiragana / Katakana / CJK compat
|
|
636
|
+
(code >= 0x3400 && code <= 0x4DBF) || // CJK Unified Extension A
|
|
637
|
+
(code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs (心跳正常 …)
|
|
638
|
+
(code >= 0xA000 && code <= 0xA4CF) || // Yi
|
|
639
|
+
(code >= 0xAC00 && code <= 0xD7A3) || // Hangul syllables
|
|
640
|
+
(code >= 0xF900 && code <= 0xFAFF) || // CJK compatibility ideographs
|
|
641
|
+
(code >= 0xFE30 && code <= 0xFE4F) || // CJK compatibility forms
|
|
642
|
+
(code >= 0xFF00 && code <= 0xFF60) || // Fullwidth ASCII
|
|
643
|
+
(code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
|
|
644
|
+
(code >= 0x1F300 && code <= 0x1FAFF) // Emoji / symbols
|
|
645
|
+
)
|
|
584
646
|
}
|
|
585
647
|
|
|
586
648
|
// (fitTranscript / blockRows / wrappedRows 视窗裁剪 + in-app 翻页逻辑已移除:
|
package/src/lib/entry-view.ts
CHANGED
|
@@ -291,6 +291,7 @@ function truncate(s: string, n: number): string {
|
|
|
291
291
|
/** Build a one-line summary of a tool call from its name + input. */
|
|
292
292
|
export function summarizeToolInput(name: string, input: any): string {
|
|
293
293
|
if (!input || typeof input !== 'object') return ''
|
|
294
|
+
name = normalizeToolName(name) // mcp__aimux__remote_exec_command → remote_exec_command
|
|
294
295
|
const cmd = (s?: string) => truncate(s ?? '', 120)
|
|
295
296
|
switch (name) {
|
|
296
297
|
case 'Bash':
|
|
@@ -298,6 +299,7 @@ export function summarizeToolInput(name: string, input: any): string {
|
|
|
298
299
|
case 'bash':
|
|
299
300
|
case 'exec':
|
|
300
301
|
case 'exec_command':
|
|
302
|
+
case 'remote_exec_command':
|
|
301
303
|
case 'shell_command':
|
|
302
304
|
case 'run_terminal_cmd':
|
|
303
305
|
return cmd(input.cmd ?? input.command ?? input.script)
|
|
@@ -345,23 +347,61 @@ export function summarizeToolInput(name: string, input: any): string {
|
|
|
345
347
|
function extractToolResult(content: any): { text: string; isError: boolean } {
|
|
346
348
|
const isError = !!content?.is_error
|
|
347
349
|
let body = content?.content
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
|
|
350
|
+
let text = ''
|
|
351
|
+
if (typeof body === 'string') text = body
|
|
352
|
+
else if (Array.isArray(body)) {
|
|
353
|
+
text = body
|
|
351
354
|
.map((b: any) => (typeof b === 'string' ? b : (b?.text ?? '')))
|
|
352
355
|
.filter(Boolean)
|
|
353
356
|
.join('\n')
|
|
354
|
-
|
|
357
|
+
} else if (typeof body === 'object' && body) {
|
|
358
|
+
text = body.text ?? body.output ?? JSON.stringify(body)
|
|
355
359
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
+
// claude MCP 工具(如 aimux remote_exec_command)的结果是 JSON 串 {"output":"...","exit_code":0}
|
|
361
|
+
// 解包出 output, 与 codex exec 的纯文本输出对齐; 再清掉终端标题/退出码探针等 shell 噪声.
|
|
362
|
+
return { text: cleanShellNoise(unwrapExecOutput(text)), isError }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* aimux remote_exec_command 等 MCP 工具把命令输出包成 {"output":"...","exit_code":0,...}
|
|
367
|
+
* JSON 串。解包出 output 字段,使 claude-code 的命令结果与 codex 的纯文本输出一致。
|
|
368
|
+
* 仅当整体是 JSON 对象且含字符串 output 字段时才解包(避免误吞本身就是 JSON 的文件内容)。
|
|
369
|
+
*/
|
|
370
|
+
function unwrapExecOutput(text: string): string {
|
|
371
|
+
const trimmed = text.trim()
|
|
372
|
+
if (!(trimmed.startsWith('{') && trimmed.endsWith('}'))) return text
|
|
373
|
+
try {
|
|
374
|
+
const obj = JSON.parse(trimmed)
|
|
375
|
+
if (obj && typeof obj === 'object' && typeof obj.output === 'string') return obj.output
|
|
376
|
+
} catch { /* 不是 JSON, 原样返回 */ }
|
|
377
|
+
return text
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* 清掉 aimux 交互式 shell 捕获里的纯噪声 (claude-code 与 codex 经 aimux 执行命令时都会产生):
|
|
382
|
+
* - OSC 终端标题序列 \x1b]0;root@host: cwd\x07 (最刺眼的乱码)
|
|
383
|
+
* - CSI 控制序列 \x1b[...m 等
|
|
384
|
+
* - aimux 退出码探针 __AIMUX_EXIT_<hex>__:<code> 及其 echo 回显
|
|
385
|
+
*/
|
|
386
|
+
function cleanShellNoise(text: string): string {
|
|
387
|
+
if (!text) return text
|
|
388
|
+
return text
|
|
389
|
+
.replace(/\x1b\][^\x1b]*?(?:\x07|\x1b\\)/g, '') // OSC 终端标题
|
|
390
|
+
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') // CSI 控制序列
|
|
391
|
+
.replace(/__AIMUX_EXIT_[0-9a-fA-F]+__(:\d+)?/g, '') // aimux 退出码标记
|
|
392
|
+
.replace(/[ \t]*\r?\n[ \t]*\r?\n[ \t]*\r?\n+/g, '\n\n') // 压连续空行
|
|
393
|
+
.trim()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 还原 claude MCP 工具长名: mcp__<server>__<tool> → <tool>, 与 codex 短名对齐。 */
|
|
397
|
+
function normalizeToolName(name: string): string {
|
|
398
|
+
const m = /^mcp__[a-zA-Z0-9_-]+__(.+)$/.exec(name)
|
|
399
|
+
return m ? m[1] : name
|
|
360
400
|
}
|
|
361
401
|
|
|
362
402
|
const TOOL_LABEL: Record<string, string> = {
|
|
363
403
|
Bash: '运行命令', bash: '运行命令', shell: '运行命令', exec: '运行命令',
|
|
364
|
-
exec_command: '运行命令', shell_command: '运行命令', run_terminal_cmd: '运行命令',
|
|
404
|
+
exec_command: '运行命令', remote_exec_command: '运行命令', shell_command: '运行命令', run_terminal_cmd: '运行命令',
|
|
365
405
|
result: '结果',
|
|
366
406
|
write_stdin: '输入命令',
|
|
367
407
|
Read: '读取文件', read_file: '读取文件',
|
|
@@ -588,7 +628,8 @@ export function viewsForEntry(entry: AnyEntry): EntryView[] {
|
|
|
588
628
|
}
|
|
589
629
|
|
|
590
630
|
export function toolLabel(name: string): string {
|
|
591
|
-
|
|
631
|
+
const n = normalizeToolName(name) // mcp__aimux__remote_exec_command → remote_exec_command → 运行命令
|
|
632
|
+
return TOOL_LABEL[n] ?? n
|
|
592
633
|
}
|
|
593
634
|
|
|
594
635
|
// ── 用户输入去重 (对齐 web viewer/rounds.ts buildRounds) ──────────────────────
|