@mobius-os/mobius 0.3.34 → 0.3.42
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/scripts/build-python-bundles.sh +2 -2
- package/src/App.tsx +18 -2
- package/src/aimux.ts +69 -11
- package/src/components/Chat.tsx +65 -11
- package/src/components/ConfigFlow.tsx +30 -6
- package/src/components/Login.tsx +5 -3
- package/src/components/PrepScreen.tsx +98 -25
- package/src/components/primitives.tsx +23 -4
- package/src/lib/cursor-keys.ts +70 -0
- package/src/lib/delete-keys.ts +4 -4
- package/src/lib/entry-view.ts +40 -0
- package/src/markdown.ts +34 -10
- package/tests/aimux.test.tsx +41 -10
- package/tests/flow.test.tsx +46 -4
- package/tests/scroll.test.tsx +3 -3
- package/tests/selection.test.tsx +1 -1
- package/tests/ui.test.tsx +271 -9
|
@@ -230,10 +230,75 @@ function toItems(arr: { id: string; name: string; description?: string }[]): Sel
|
|
|
230
230
|
return arr.map(s => ({ label: s.name, value: s.id, desc: s.description }))
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
// Search-first picker used by the project and issue screens. The search field
|
|
234
|
+
// owns the initial focus so a user can type immediately; Down hands control to
|
|
235
|
+
// the normal Select for keyboard navigation. Enter in the search field chooses
|
|
236
|
+
// the first matching row, which keeps the common "type a unique name, Enter"
|
|
237
|
+
// workflow to one step.
|
|
238
|
+
function SearchableSelect({
|
|
239
|
+
items,
|
|
240
|
+
createItem,
|
|
241
|
+
title,
|
|
242
|
+
placeholder,
|
|
243
|
+
onSelect,
|
|
244
|
+
onCreate,
|
|
245
|
+
onQuit,
|
|
246
|
+
}: {
|
|
247
|
+
items: SelectItem[]
|
|
248
|
+
createItem?: SelectItem
|
|
249
|
+
title: string
|
|
250
|
+
placeholder: string
|
|
251
|
+
onSelect: (value: string) => void
|
|
252
|
+
onCreate?: () => void
|
|
253
|
+
onQuit?: () => void
|
|
254
|
+
}) {
|
|
255
|
+
const [query, setQuery] = useState('')
|
|
256
|
+
const [focus, setFocus] = useState<'search' | 'list'>('search')
|
|
257
|
+
const needle = query.trim().toLocaleLowerCase()
|
|
258
|
+
const matches = (item: SelectItem) => {
|
|
259
|
+
if (!needle) return true
|
|
260
|
+
return `${item.label}\n${item.desc ?? ''}\n${item.value}`.toLocaleLowerCase().includes(needle)
|
|
261
|
+
}
|
|
262
|
+
const filtered = items.filter(matches)
|
|
263
|
+
const createMatches = createItem && matches(createItem)
|
|
264
|
+
const visibleItems = createItem && (!needle || createMatches)
|
|
265
|
+
? [createItem, ...filtered]
|
|
266
|
+
: filtered
|
|
267
|
+
|
|
268
|
+
function choose(value: string) {
|
|
269
|
+
if (value === createItem?.value) onCreate?.()
|
|
270
|
+
else onSelect(value)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<Box flexDirection="column">
|
|
275
|
+
<Text bold color="cyan">{title}</Text>
|
|
276
|
+
<Box marginTop={1} flexDirection="column">
|
|
277
|
+
<TextInput
|
|
278
|
+
value={query}
|
|
279
|
+
onChange={setQuery}
|
|
280
|
+
focused={focus === 'search'}
|
|
281
|
+
prompt="搜索:"
|
|
282
|
+
placeholder={placeholder}
|
|
283
|
+
onArrowDown={() => setFocus('list')}
|
|
284
|
+
onArrowUp={() => setFocus('list')}
|
|
285
|
+
onSubmit={() => { if (visibleItems.length) choose(visibleItems[0].value) }}
|
|
286
|
+
onEscape={() => onQuit?.()}
|
|
287
|
+
/>
|
|
288
|
+
{query.trim() && !filtered.length
|
|
289
|
+
? <Text color="yellow">没有匹配的项目,请修改搜索</Text>
|
|
290
|
+
: focus === 'search' && query.trim() ? <Text color="gray">匹配 {filtered.length} 项 · ↓进入列表</Text> : null}
|
|
291
|
+
{focus === 'list' && !visibleItems.length
|
|
292
|
+
? <Text color="yellow">没有匹配的项目,请按 Esc 修改搜索</Text>
|
|
293
|
+
: <Select
|
|
294
|
+
items={visibleItems}
|
|
295
|
+
focused={focus === 'list'}
|
|
296
|
+
onBack={() => setFocus('search')}
|
|
297
|
+
onSelect={choose}
|
|
298
|
+
/>}
|
|
299
|
+
</Box>
|
|
300
|
+
</Box>
|
|
301
|
+
)
|
|
237
302
|
}
|
|
238
303
|
|
|
239
304
|
// ── Project picker ───────────────────────────────────────────────────────────
|
|
@@ -264,22 +329,25 @@ function ProjectPicker({ cwd, projects, statusMsg, onPick, onCreate, onQuit }: {
|
|
|
264
329
|
)
|
|
265
330
|
}
|
|
266
331
|
|
|
267
|
-
const items: SelectItem[] =
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}),
|
|
273
|
-
]
|
|
332
|
+
const items: SelectItem[] = projects.map(p => ({
|
|
333
|
+
label: p.name,
|
|
334
|
+
desc: p.description,
|
|
335
|
+
value: p.id,
|
|
336
|
+
}))
|
|
274
337
|
return (
|
|
275
338
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
276
|
-
<Text bold color="cyan">选择当前路径的绑定项目</Text>
|
|
277
339
|
<Text color="gray">{cwd}</Text>
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
340
|
+
<SearchableSelect
|
|
341
|
+
title="选择当前路径的绑定项目"
|
|
342
|
+
placeholder="输入项目名或描述"
|
|
343
|
+
items={items}
|
|
344
|
+
createItem={{ label: '➕ 创建新项目', value: '__create__', desc: '绑定到当前路径' }}
|
|
345
|
+
onSelect={v => onPick(projects.find(p => p.id === v)!)}
|
|
346
|
+
onCreate={() => setMode('create')}
|
|
347
|
+
onQuit={onQuit}
|
|
348
|
+
/>
|
|
281
349
|
{statusMsg ? <Text color="yellow">{statusMsg}</Text> : null}
|
|
282
|
-
<Text color="gray"
|
|
350
|
+
<Text color="gray">输入关键词筛选 · ↓进入列表 · Esc 退出</Text>
|
|
283
351
|
</Box>
|
|
284
352
|
)
|
|
285
353
|
}
|
|
@@ -306,18 +374,23 @@ function IssuePicker({ issues, onPick, onCreate }: {
|
|
|
306
374
|
</Box>
|
|
307
375
|
)
|
|
308
376
|
}
|
|
309
|
-
const items: SelectItem[] =
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
377
|
+
const items: SelectItem[] = issues.map(i => ({
|
|
378
|
+
label: i.title,
|
|
379
|
+
desc: i.description,
|
|
380
|
+
value: i.id,
|
|
381
|
+
}))
|
|
313
382
|
return (
|
|
314
383
|
<Box flexDirection="column">
|
|
315
|
-
<Text bold color="cyan">选择任务(Issue)</Text>
|
|
316
384
|
<Text color="gray">偏好设置将保存在所选任务内部</Text>
|
|
317
385
|
<Box marginTop={1}>
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
386
|
+
<SearchableSelect
|
|
387
|
+
title="选择任务(Issue)"
|
|
388
|
+
placeholder="输入任务标题或描述"
|
|
389
|
+
items={items}
|
|
390
|
+
createItem={{ label: issues.length ? '➕ 创建新任务' : '➕ 创建新任务(尚无任务)', value: '__create__' }}
|
|
391
|
+
onSelect={v => onPick(issues.find(i => i.id === v)!)}
|
|
392
|
+
onCreate={() => setMode('create-name')}
|
|
393
|
+
/>
|
|
321
394
|
</Box>
|
|
322
395
|
</Box>
|
|
323
396
|
)
|
|
@@ -332,8 +405,8 @@ function ModelPicker({ options, defaultKey, onSelect }: {
|
|
|
332
405
|
if (!options.length) return <Text color="gray">加载模型列表…</Text>
|
|
333
406
|
const items: SelectItem[] = options.map(o => ({
|
|
334
407
|
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
335
|
-
value: o.key,
|
|
336
408
|
desc: o.sub,
|
|
409
|
+
value: o.key,
|
|
337
410
|
}))
|
|
338
411
|
return (
|
|
339
412
|
<Box flexDirection="column">
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import React, { useEffect, useRef, useState } from 'react'
|
|
6
6
|
import { Box, Text, useInput, useStdout, useStdin, type Key } from 'ink'
|
|
7
|
-
import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
|
|
7
|
+
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousWordBoundary, nextWordBoundary } from '../lib/delete-keys.js'
|
|
8
|
+
import { useCursorKeyCapture } from '../lib/cursor-keys.js'
|
|
8
9
|
|
|
9
10
|
/** Return false when a mounted listener deliberately did not consume the input. */
|
|
10
11
|
type InputHandler = (input: string, key: Key) => void | false
|
|
@@ -318,6 +319,14 @@ export function TextInput(props: TextInputProps) {
|
|
|
318
319
|
const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
|
|
319
320
|
edit(text, nextCursor)
|
|
320
321
|
})
|
|
322
|
+
useCursorKeyCapture(focused, (intent) => {
|
|
323
|
+
const current = valueRef.current
|
|
324
|
+
const at = clampCursor(current, cursorRef.current)
|
|
325
|
+
const next = intent === 'home' ? 0 : intent === 'end' ? current.length
|
|
326
|
+
: intent === 'backward-word' ? previousWordBoundary(current, at) : nextWordBoundary(current, at)
|
|
327
|
+
cursorRef.current = next
|
|
328
|
+
setCursor(next)
|
|
329
|
+
})
|
|
321
330
|
|
|
322
331
|
useStableInput((input, key) => {
|
|
323
332
|
if (isMouseInput(input)) return
|
|
@@ -343,6 +352,7 @@ export function TextInput(props: TextInputProps) {
|
|
|
343
352
|
edit(text, nextCursor)
|
|
344
353
|
return
|
|
345
354
|
}
|
|
355
|
+
if (key.ctrl && (key.leftArrow || key.rightArrow)) return
|
|
346
356
|
if (key.leftArrow) { setCursor(c => Math.max(0, c - 1)); return }
|
|
347
357
|
if (key.rightArrow) { setCursor(c => Math.min(value.length, c + 1)); return }
|
|
348
358
|
if (key.ctrl && input === 'a') { setCursor(0); return }
|
|
@@ -421,6 +431,12 @@ export interface SelectItem {
|
|
|
421
431
|
desc?: string
|
|
422
432
|
}
|
|
423
433
|
|
|
434
|
+
/** Keep a picker explanation on the item's main row; Select truncates that row. */
|
|
435
|
+
export function inlineSelectLabel(label: string, detail?: string): string {
|
|
436
|
+
const oneLine = detail?.replace(/\s*\n\s*/g, ' ⏎ ').replace(/[ \t]+/g, ' ').trim()
|
|
437
|
+
return oneLine ? `${label} - ${oneLine}` : label
|
|
438
|
+
}
|
|
439
|
+
|
|
424
440
|
export interface SelectProps {
|
|
425
441
|
items: SelectItem[]
|
|
426
442
|
mode?: 'single' | 'multi'
|
|
@@ -491,17 +507,20 @@ export function Select(props: SelectProps) {
|
|
|
491
507
|
const isActive = realIdx === active
|
|
492
508
|
const checked = mode === 'multi' ? selectedSet.has(it.value) : false
|
|
493
509
|
const marker = mode === 'multi' ? (checked ? '☑' : '☐') : isActive ? '❯' : ' '
|
|
510
|
+
// Keep picker rows compact: descriptions belong on the highlighted
|
|
511
|
+
// row only. Unfocused rows show just their label so a long list does
|
|
512
|
+
// not turn every item into a multi-line block.
|
|
513
|
+
const rowLabel = isActive ? inlineSelectLabel(it.label, it.desc) : it.label
|
|
494
514
|
return (
|
|
495
|
-
<Box key={it.value}
|
|
515
|
+
<Box key={it.value}>
|
|
496
516
|
<Text
|
|
497
517
|
color={isActive ? 'black' : undefined}
|
|
498
518
|
backgroundColor={isActive ? 'cyan' : undefined}
|
|
499
519
|
bold={isActive}
|
|
500
520
|
wrap="truncate-end"
|
|
501
521
|
>
|
|
502
|
-
{marker} {
|
|
522
|
+
{marker} {rowLabel}
|
|
503
523
|
</Text>
|
|
504
|
-
{isActive && it.desc ? <Text color="gray" wrap="truncate-end"> {it.desc}</Text> : null}
|
|
505
524
|
</Box>
|
|
506
525
|
)
|
|
507
526
|
})}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Raw terminal cursor-key capture for keys Ink does not expose in `Key`. */
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { useStdin } from 'ink'
|
|
5
|
+
|
|
6
|
+
export type CursorIntent = 'home' | 'end' | 'backward-word' | 'forward-word'
|
|
7
|
+
|
|
8
|
+
// Home/End vary by terminal/application mode. Ctrl+Left/Right are the xterm
|
|
9
|
+
// CSI modifier-5 forms; the shorter 5D/5C forms are emitted by some tmux and
|
|
10
|
+
// ConPTY bridges. Longest sequences come first so prefixes cannot win early.
|
|
11
|
+
const CURSOR_SEQUENCES: ReadonlyArray<readonly [string, CursorIntent]> = [
|
|
12
|
+
['\x1b[1;5D', 'backward-word'],
|
|
13
|
+
['\x1b[1;5C', 'forward-word'],
|
|
14
|
+
['\x1b[5D', 'backward-word'],
|
|
15
|
+
['\x1b[5C', 'forward-word'],
|
|
16
|
+
['\x1b[1~', 'home'],
|
|
17
|
+
['\x1b[7~', 'home'],
|
|
18
|
+
['\x1b[4~', 'end'],
|
|
19
|
+
['\x1b[8~', 'end'],
|
|
20
|
+
['\x1b[H', 'home'],
|
|
21
|
+
['\x1b[F', 'end'],
|
|
22
|
+
['\x1bOH', 'home'],
|
|
23
|
+
['\x1bOF', 'end'],
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export function classifyCursorSequence(raw: string): { intent: CursorIntent; length: number } | null {
|
|
27
|
+
for (const [sequence, intent] of CURSOR_SEQUENCES) {
|
|
28
|
+
if (raw.startsWith(sequence)) return { intent, length: sequence.length }
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isCursorPrefix(raw: string): boolean {
|
|
34
|
+
return CURSOR_SEQUENCES.some(([sequence]) => sequence.startsWith(raw))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Capture physical Home/End and Ctrl+arrow bytes without relying on Ink's lossy key object. */
|
|
38
|
+
export function useCursorKeyCapture(
|
|
39
|
+
enabled: boolean,
|
|
40
|
+
onCursor: (intent: CursorIntent) => void,
|
|
41
|
+
): void {
|
|
42
|
+
const { internal_eventEmitter } = useStdin()
|
|
43
|
+
const enabledRef = useRef(enabled)
|
|
44
|
+
const onCursorRef = useRef(onCursor)
|
|
45
|
+
enabledRef.current = enabled
|
|
46
|
+
onCursorRef.current = onCursor
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!internal_eventEmitter) return
|
|
50
|
+
let buf = ''
|
|
51
|
+
const handler = (chunk: unknown) => {
|
|
52
|
+
buf += String(chunk)
|
|
53
|
+
while (buf) {
|
|
54
|
+
const match = classifyCursorSequence(buf)
|
|
55
|
+
if (match) {
|
|
56
|
+
buf = buf.slice(match.length)
|
|
57
|
+
if (enabledRef.current) onCursorRef.current(match.intent)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (isCursorPrefix(buf)) break
|
|
61
|
+
// Ordinary text is handled by Ink's useInput; only retain a possible
|
|
62
|
+
// incomplete escape prefix so split terminal sequences still match.
|
|
63
|
+
const nextEsc = buf.indexOf('\x1b', buf.startsWith('\x1b') ? 1 : 0)
|
|
64
|
+
buf = nextEsc >= 0 ? buf.slice(nextEsc) : ''
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
internal_eventEmitter.on('input', handler)
|
|
68
|
+
return () => { internal_eventEmitter.off('input', handler) }
|
|
69
|
+
}, [internal_eventEmitter])
|
|
70
|
+
}
|
package/src/lib/delete-keys.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function nextCursorBoundary(text: string, cursor: number): number {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/** Backward-word boundary: skip trailing whitespace, then the word before it. */
|
|
62
|
-
function
|
|
62
|
+
export function previousWordBoundary(text: string, at: number): number {
|
|
63
63
|
let i = at
|
|
64
64
|
while (i > 0 && /\s/.test(text[i - 1])) i--
|
|
65
65
|
while (i > 0 && !/\s/.test(text[i - 1])) i--
|
|
@@ -67,7 +67,7 @@ function backwardWordBoundary(text: string, at: number): number {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** Forward-word boundary: skip leading whitespace, then the word after it. */
|
|
70
|
-
function
|
|
70
|
+
export function nextWordBoundary(text: string, at: number): number {
|
|
71
71
|
let i = at
|
|
72
72
|
while (i < text.length && /\s/.test(text[i])) i++
|
|
73
73
|
while (i < text.length && !/\s/.test(text[i])) i++
|
|
@@ -93,12 +93,12 @@ export function applyDeleteIntent(
|
|
|
93
93
|
return { text: text.slice(0, at) + text.slice(next), cursor: at }
|
|
94
94
|
}
|
|
95
95
|
case 'backward-word': {
|
|
96
|
-
const start =
|
|
96
|
+
const start = previousWordBoundary(text, at)
|
|
97
97
|
if (start === at) return { text, cursor: at }
|
|
98
98
|
return { text: text.slice(0, start) + text.slice(at), cursor: start }
|
|
99
99
|
}
|
|
100
100
|
case 'forward-word': {
|
|
101
|
-
const end =
|
|
101
|
+
const end = nextWordBoundary(text, at)
|
|
102
102
|
if (end === at) return { text, cursor: at }
|
|
103
103
|
return { text: text.slice(0, at) + text.slice(end), cursor: at }
|
|
104
104
|
}
|
package/src/lib/entry-view.ts
CHANGED
|
@@ -87,6 +87,31 @@ function entryUserText(entry: AnyEntry): string {
|
|
|
87
87
|
|
|
88
88
|
const ENV_CONTEXT_RE = /<environment_context\b[^>]*>[\s\S]*?<\/environment_context>/gi
|
|
89
89
|
|
|
90
|
+
// ── Claude Code 本地命令产物标签 (/compact 等) ─────────────────────────────
|
|
91
|
+
// slash command 在 claude-code jsonl 里以 user 外壳 + 下列标签出现, 不是人类提问:
|
|
92
|
+
// <command-name>/compact</command-name> 等 命令回显 (噪声)
|
|
93
|
+
// <local-command-caveat>…</local-command-caveat> "由本地命令产生" 提示 (噪声)
|
|
94
|
+
// <local-command-stdout>Compacted …</local-command-stdout> 命令输出 (压缩完成信号)
|
|
95
|
+
// 对齐 web entry-extract.ts 的 extractLocalCommandParts (精简版), 渲染时不能把
|
|
96
|
+
// 标签原文当用户消息显示.
|
|
97
|
+
const LOCAL_COMMAND_TAG_PATTERN = /<(local-command-stdout|local-command-caveat|command-name|command-message|command-args)>\s*([\s\S]*?)<\/\1>/gi
|
|
98
|
+
|
|
99
|
+
interface LocalCommandPart { tag: string; body: string }
|
|
100
|
+
|
|
101
|
+
function extractLocalCommandParts(entry: AnyEntry): LocalCommandPart[] {
|
|
102
|
+
if (entry?.type !== 'user') return []
|
|
103
|
+
const text = entryUserText(entry)
|
|
104
|
+
if (!text || !text.includes('<')) return []
|
|
105
|
+
const parts: LocalCommandPart[] = []
|
|
106
|
+
LOCAL_COMMAND_TAG_PATTERN.lastIndex = 0
|
|
107
|
+
let m: RegExpExecArray | null
|
|
108
|
+
while ((m = LOCAL_COMMAND_TAG_PATTERN.exec(text))) {
|
|
109
|
+
const body = m[2].replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim()
|
|
110
|
+
parts.push({ tag: m[1].toLowerCase(), body })
|
|
111
|
+
}
|
|
112
|
+
return parts
|
|
113
|
+
}
|
|
114
|
+
|
|
90
115
|
/**
|
|
91
116
|
* 整卡隐藏的噪声: 对齐 web entry-classify.ts isHiddenJsonlNoiseEntry 的 7 类
|
|
92
117
|
* - token_count : codex 每轮 token 用量统计 (event_msg)
|
|
@@ -558,6 +583,21 @@ export function viewsForBlock(block: Block): EntryView[] {
|
|
|
558
583
|
}
|
|
559
584
|
return out
|
|
560
585
|
}
|
|
586
|
+
// Claude Code 本地命令产物 (/compact 等): 命令回显/caveat 标签是噪声 → 整条
|
|
587
|
+
// 隐藏; local-command-stdout 渲染成 system 行, "Compacted …" 对齐 codex 的
|
|
588
|
+
// context_compacted 事件显示 "◇ 上下文已压缩"。
|
|
589
|
+
const localParts = extractLocalCommandParts(entry)
|
|
590
|
+
if (localParts.length > 0) {
|
|
591
|
+
const out: EntryView[] = []
|
|
592
|
+
for (const part of localParts) {
|
|
593
|
+
if (part.tag !== 'local-command-stdout' || !part.body) continue
|
|
594
|
+
const text = /^compacted\b/i.test(part.body)
|
|
595
|
+
? `◇ 上下文已压缩 · ${part.body}`
|
|
596
|
+
: part.body
|
|
597
|
+
out.push({ kind: 'system', text: truncate(text, 160) })
|
|
598
|
+
}
|
|
599
|
+
return out.length ? out : [{ kind: 'skip' }]
|
|
600
|
+
}
|
|
561
601
|
const text = entryUserText(entry)
|
|
562
602
|
return text ? [{ kind: 'user', text: stripUserFraming(text) }] : [{ kind: 'skip' }]
|
|
563
603
|
}
|
package/src/markdown.ts
CHANGED
|
@@ -12,6 +12,30 @@ import chalk from 'chalk'
|
|
|
12
12
|
import { highlight, supportsLanguage } from 'cli-highlight'
|
|
13
13
|
import { lexer, type Token, type Tokens } from 'marked'
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Decode HTML entities that marked's lexer injects into text tokens.
|
|
17
|
+
* marked encodes `' " < > &` as `' " < > &` even when
|
|
18
|
+
* only lexing (not rendering to HTML). The TUI renders to a terminal so
|
|
19
|
+
* we must reverse that encoding ourselves.
|
|
20
|
+
*/
|
|
21
|
+
const HTML_ENTITY_RE = /&(?:#(x?)([0-9a-fA-F]+)|(amp|lt|gt|quot|#39));/g
|
|
22
|
+
function decodeHtmlEntities(s: string): string {
|
|
23
|
+
return s.replace(HTML_ENTITY_RE, (_, hex: string | undefined, num: string, named: string | undefined) => {
|
|
24
|
+
if (named) {
|
|
25
|
+
switch (named) {
|
|
26
|
+
case 'amp': return '&'
|
|
27
|
+
case 'lt': return '<'
|
|
28
|
+
case 'gt': return '>'
|
|
29
|
+
case 'quot': return '"'
|
|
30
|
+
case '#39': return "'"
|
|
31
|
+
default: return _
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const code = parseInt(num, hex ? 16 : 10)
|
|
35
|
+
return String.fromCodePoint(code)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
15
39
|
export interface RenderedMarkdownLine {
|
|
16
40
|
text: string
|
|
17
41
|
code: boolean
|
|
@@ -43,7 +67,7 @@ function renderInlineOne(t: Token): string {
|
|
|
43
67
|
const anyT = t as any
|
|
44
68
|
switch (t.type) {
|
|
45
69
|
case 'text':
|
|
46
|
-
return anyT.tokens ? renderInline(anyT.tokens) : escapeAnsiReset(anyT.text)
|
|
70
|
+
return anyT.tokens ? renderInline(anyT.tokens) : escapeAnsiReset(decodeHtmlEntities(anyT.text))
|
|
47
71
|
case 'strong':
|
|
48
72
|
return chalk.bold(renderInline(anyT.tokens))
|
|
49
73
|
case 'em':
|
|
@@ -51,20 +75,20 @@ function renderInlineOne(t: Token): string {
|
|
|
51
75
|
case 'del':
|
|
52
76
|
return chalk.dim.strikethrough(renderInline(anyT.tokens))
|
|
53
77
|
case 'codespan':
|
|
54
|
-
return chalk.cyanBright(anyT.text)
|
|
78
|
+
return chalk.cyanBright(decodeHtmlEntities(anyT.text))
|
|
55
79
|
case 'link': {
|
|
56
80
|
const label = renderInline(anyT.tokens) || anyT.href
|
|
57
81
|
return anyT.href && label !== anyT.href ? `${chalk.cyan(label)} (${chalk.dim.underline(anyT.href)})` : chalk.cyan(label)
|
|
58
82
|
}
|
|
59
83
|
case 'image':
|
|
60
|
-
return chalk.magentaBright(`[图片: ${anyT.href || anyT.text}]`)
|
|
84
|
+
return chalk.magentaBright(`[图片: ${anyT.href || decodeHtmlEntities(anyT.text)}]`)
|
|
61
85
|
case 'br':
|
|
62
86
|
return '\n'
|
|
63
87
|
case 'escape':
|
|
64
88
|
case 'html':
|
|
65
|
-
return anyT.text
|
|
89
|
+
return anyT.text ? decodeHtmlEntities(anyT.text) : ''
|
|
66
90
|
default:
|
|
67
|
-
return anyT.text
|
|
91
|
+
return anyT.text ? decodeHtmlEntities(anyT.text) : renderInline(anyT.tokens)
|
|
68
92
|
}
|
|
69
93
|
}
|
|
70
94
|
|
|
@@ -74,7 +98,7 @@ function escapeAnsiReset(s: string): string {
|
|
|
74
98
|
}
|
|
75
99
|
|
|
76
100
|
function renderTable(t: Tokens.Table): string {
|
|
77
|
-
const cell = (toks: any) => renderInline(toks?.tokens ?? [{ type: 'text', text: toks?.text ?? '' }])
|
|
101
|
+
const cell = (toks: any) => renderInline(toks?.tokens ?? [{ type: 'text', text: decodeHtmlEntities(toks?.text ?? '') }])
|
|
78
102
|
const header = t.header.map((h) => cell(h)).join(' | ')
|
|
79
103
|
const rows = t.rows.map((r) => r.map((c) => cell(c)).join(' | ')).join('\n')
|
|
80
104
|
return chalk.bold(header) + '\n' + chalk.dim('-'.repeat(Math.min(header.length, 80))) + '\n' + rows
|
|
@@ -121,7 +145,7 @@ function renderBlock(t: Token): string {
|
|
|
121
145
|
case 'paragraph':
|
|
122
146
|
return renderInline(anyT.tokens)
|
|
123
147
|
case 'code': {
|
|
124
|
-
return renderCode(anyT.text, anyT.lang)
|
|
148
|
+
return renderCode(decodeHtmlEntities(anyT.text), anyT.lang)
|
|
125
149
|
}
|
|
126
150
|
case 'blockquote': {
|
|
127
151
|
const inner = (anyT.tokens as Token[]).map(renderBlock).join('\n')
|
|
@@ -142,9 +166,9 @@ function renderBlock(t: Token): string {
|
|
|
142
166
|
case 'space':
|
|
143
167
|
return ''
|
|
144
168
|
case 'html':
|
|
145
|
-
return chalk.dim(anyT.text ?? '')
|
|
169
|
+
return chalk.dim(decodeHtmlEntities(anyT.text ?? ''))
|
|
146
170
|
default:
|
|
147
|
-
return anyT.text
|
|
171
|
+
return anyT.text ? decodeHtmlEntities(anyT.text) : renderInline(anyT.tokens)
|
|
148
172
|
}
|
|
149
173
|
}
|
|
150
174
|
|
|
@@ -156,5 +180,5 @@ function renderListItemBody(item: any): string {
|
|
|
156
180
|
.filter(Boolean)
|
|
157
181
|
.join('\n')
|
|
158
182
|
}
|
|
159
|
-
return item.text
|
|
183
|
+
return item.text ? decodeHtmlEntities(item.text) : ''
|
|
160
184
|
}
|
package/tests/aimux.test.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import os from 'node:os'
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { render } from 'ink-testing-library'
|
|
9
9
|
import { AimuxStatusLine } from '../src/components/AimuxStatus.js'
|
|
10
|
-
import { AimuxSupervisor, probeAimuxBridgeConnection, bundleArch, bundleUrl, spawnLauncher, ensureFromBundle, downloadBundleForTest, reverseConnectArgs, aimuxLogPath, bundleHealthCheckCode } from '../src/aimux.js'
|
|
10
|
+
import { AimuxSupervisor, probeAimuxBridgeConnection, bundleArch, bundleUrl, spawnLauncher, ensureFromBundle, downloadBundleForTest, reverseConnectArgs, pickSilentFlag, aimuxLogPath, bundleHealthCheckCode, tuiAimuxIdentifier } from '../src/aimux.js'
|
|
11
11
|
|
|
12
12
|
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
13
13
|
let pass = 0, fail = 0
|
|
@@ -78,11 +78,11 @@ async function testBundleArchAndUrl() {
|
|
|
78
78
|
const arch = bundleArch()
|
|
79
79
|
ok(arch === 'linux-x64' || arch === 'win-x64' || arch === 'mac-x64', `bundleArch returns a supported arch on this host (${arch})`)
|
|
80
80
|
const before = bundleUrl('linux-x64')
|
|
81
|
-
ok(before.includes('mobius-python-linux-x64-
|
|
81
|
+
ok(before.includes('mobius-python-linux-x64-v3') && before.endsWith('.zip'), 'bundleUrl follows the fixed filename pattern')
|
|
82
82
|
const saved = process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL
|
|
83
83
|
process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL = 'https://example.test/cdn/'
|
|
84
84
|
try {
|
|
85
|
-
ok(bundleUrl('win-x64') === 'https://example.test/cdn/mobius-python-win-x64-
|
|
85
|
+
ok(bundleUrl('win-x64') === 'https://example.test/cdn/mobius-python-win-x64-v3.zip', 'MOBIUS_TUI_PYTHON_BUNDLE_URL overrides the CDN base and trims trailing slash')
|
|
86
86
|
} finally { if (saved === undefined) delete process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL; else process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL = saved }
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -133,12 +133,41 @@ async function testSpawnLauncher() {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
function testReverseConnectArgs() {
|
|
136
|
-
console.log('\n[AIMUX 6] reverse connect
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
ok(
|
|
141
|
-
ok(
|
|
136
|
+
console.log('\n[AIMUX 6] reverse connect silent flag adapts to installed aimux')
|
|
137
|
+
// Old aimux (PyPI 0.1.20 / cached bundle 0.1.21): advertises only --silent-shell.
|
|
138
|
+
// Must NOT send the newer --slient-v2 it doesn't know — that is the crash-loop bug.
|
|
139
|
+
const oldWin = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32', '--silent-shell')
|
|
140
|
+
ok(oldWin.includes('--silent-shell'), 'old aimux gets the --silent-shell flag it supports')
|
|
141
|
+
ok(!oldWin.includes('--slient-v2') && !oldWin.includes('--silent-v2'), 'old aimux never gets the unsupported v2 flag (no crash-loop)')
|
|
142
|
+
// New aimux (0.1.22+): probe resolves the correctly-spelled --silent-v2.
|
|
143
|
+
const newWin = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32', '--silent-v2')
|
|
144
|
+
ok(newWin.includes('--silent-v2'), 'new aimux gets the no-console v2 flag')
|
|
145
|
+
// Probe found nothing supported (or pre-probe default): send nothing, stay alive.
|
|
146
|
+
const bareWin = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32', null)
|
|
147
|
+
ok(!bareWin.some(a => a === '--slient-v2' || a === '--silent-v2' || a === '--silent-shell'), 'unknown aimux gets no silent flag rather than crash-looping')
|
|
148
|
+
// Off-Windows: never any silent flag, regardless of what the probe found.
|
|
149
|
+
const linux = reverseConnectArgs('https://mobius.test/', 'tui-linux', 'jwt-test', 'linux', '--silent-v2')
|
|
150
|
+
ok(!linux.includes('--silent-v2') && !linux.includes('--silent-shell'), 'non-Windows never receives a Windows-only flag')
|
|
151
|
+
ok(oldWin[2] === 'https://mobius.test/aimux_bridge', 'reverse connection normalizes the bridge URL')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function testPickSilentFlag() {
|
|
155
|
+
console.log('\n[AIMUX 6b] pickSilentFlag reads what aimux advertises')
|
|
156
|
+
ok(pickSilentFlag(' --slient-v2, --silent-v2 Hide console.', 'win32') === '--silent-v2', 'prefers correct --silent-v2 spelling when both aliases are advertised')
|
|
157
|
+
ok(pickSilentFlag(' --slient-v2 Hide console.', 'win32') === '--slient-v2', 'falls back to the historical --slient-v2 alias')
|
|
158
|
+
ok(pickSilentFlag(' --silent-shell Hide console.', 'win32') === '--silent-shell', 'old aimux advertising only --silent-shell')
|
|
159
|
+
ok(pickSilentFlag('Usage: aimux reverse connect ...', 'win32') === null, 'unsupported aimux → null (send nothing, avoid crash-loop)')
|
|
160
|
+
ok(pickSilentFlag(' --silent-v2 Hide console.', 'linux') === null, 'off-Windows → always null')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function testAimuxIdentifierScopesWorkspace() {
|
|
164
|
+
console.log('\n[AIMUX 6a] reverse client identifier workspace isolation')
|
|
165
|
+
const first = tuiAimuxIdentifier('same-host', '/work/project-a')
|
|
166
|
+
const firstAgain = tuiAimuxIdentifier('same-host', '/work/project-a')
|
|
167
|
+
const second = tuiAimuxIdentifier('same-host', '/work/project-b')
|
|
168
|
+
ok(first === firstAgain, 'identifier is stable for the same host and workspace')
|
|
169
|
+
ok(first !== second, 'different workspaces on one host do not replace each other')
|
|
170
|
+
ok(/^tui-same-host-[a-f0-9]{10}$/.test(first), 'identifier remains bridge-safe and recognizable')
|
|
142
171
|
}
|
|
143
172
|
|
|
144
173
|
function testBundleHealthCheck() {
|
|
@@ -146,7 +175,7 @@ function testBundleHealthCheck() {
|
|
|
146
175
|
const win = bundleHealthCheckCode('win32')
|
|
147
176
|
const linux = bundleHealthCheckCode('linux')
|
|
148
177
|
ok(win.includes('aimux.bridge_client') && win.includes('win32_setctime'), 'Windows bundle probe imports the real bridge path and its platform dependency')
|
|
149
|
-
ok(win.includes("aimux.__version__ == '0.1.
|
|
178
|
+
ok(win.includes("aimux.__version__ == '0.1.23'"), 'bundle probe rejects stale AIMUX versions')
|
|
150
179
|
ok(!linux.includes('win32_setctime'), 'non-Windows bundle probe does not require the Windows-only package')
|
|
151
180
|
}
|
|
152
181
|
|
|
@@ -199,6 +228,8 @@ async function main() {
|
|
|
199
228
|
await testBundleArchAndUrl()
|
|
200
229
|
await testSpawnLauncher()
|
|
201
230
|
testReverseConnectArgs()
|
|
231
|
+
testPickSilentFlag()
|
|
232
|
+
testAimuxIdentifierScopesWorkspace()
|
|
202
233
|
testBundleHealthCheck()
|
|
203
234
|
await testEnsureFromBundleReady()
|
|
204
235
|
await testDownloadBundleStream()
|