@mobius-os/mobius 0.3.0 → 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 +1 -1
- package/src/aimux.ts +6 -1
- 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
package/src/aimux.ts
CHANGED
|
@@ -266,8 +266,10 @@ export class AimuxSupervisor {
|
|
|
266
266
|
)
|
|
267
267
|
this.child = child
|
|
268
268
|
this.startHeartbeat()
|
|
269
|
+
let tail = '' // 缓存 aimux 最近输出, 进程异常退出时带进状态行, 便于诊断(code=1 不再是黑盒)
|
|
269
270
|
const classify = (buf: Buffer) => {
|
|
270
271
|
const text = buf.toString('utf8')
|
|
272
|
+
tail = (tail + text).slice(-4000)
|
|
271
273
|
if (!this.bridgeConnected && /connected|registered|event stream|heartbeat|sse/i.test(text)) {
|
|
272
274
|
onStatus({ state: 'starting', phase: 'heartbeat', detail: 'AIMUX 已启动,等待 bridge 心跳确认…', identifier })
|
|
273
275
|
} else if (/connection (refused|reset|closed|error)|failed to connect|unauthorized|forbidden|token.*invalid/i.test(text)) {
|
|
@@ -281,7 +283,10 @@ export class AimuxSupervisor {
|
|
|
281
283
|
this.child = null
|
|
282
284
|
this.stopHeartbeat()
|
|
283
285
|
if (this.stopping) { onStatus({ state: 'stopped', phase: 'idle', detail: 'AIMUX 已停止', identifier }); return }
|
|
284
|
-
|
|
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)
|
|
285
290
|
})
|
|
286
291
|
}
|
|
287
292
|
|
|
@@ -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) ──────────────────────
|