@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.
@@ -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
- function flattenDesc(s?: string): string {
235
- if (!s) return ''
236
- return s.replace(/\s*\n\s*/g, ' ').replace(/[ \t]+/g, ' ').trim()
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
- { label: '➕ 创建新项目', value: '__create__', desc: '绑定到当前路径' },
269
- ...projects.map(p => {
270
- const desc = flattenDesc(p.description)
271
- return { label: desc ? `${p.name} — ${desc}` : p.name, value: p.id }
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
- <Box marginTop={1}>
279
- <Select items={items} onBack={onQuit} onSelect={v => v === '__create__' ? setMode('create') : onPick(projects.find(p => p.id === v)!)} />
280
- </Box>
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">↑↓ 选择 · 回车确认 · Esc 退出</Text>
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
- { label: '➕ 创建新任务', value: '__create__' },
311
- ...issues.map(i => ({ label: i.title, value: i.id, desc: i.description })),
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
- {issues.length === 0 && mode === 'list'
319
- ? <Select items={[{ label: '➕ 创建新任务(尚无任务)', value: '__create__' }]} onSelect={() => setMode('create-name')} />
320
- : <Select items={items} onSelect={v => v === '__create__' ? setMode('create-name') : onPick(issues.find(i => i.id === v)!)} />}
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} flexDirection="column">
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} {it.label}
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
+ }
@@ -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 backwardWordBoundary(text: string, at: number): number {
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 forwardWordBoundary(text: string, at: number): number {
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 = backwardWordBoundary(text, at)
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 = forwardWordBoundary(text, at)
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
  }
@@ -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 `&#39; &quot; &lt; &gt; &amp;` 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 ?? renderInline(anyT.tokens)
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 ?? renderInline(anyT.tokens)
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
  }
@@ -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-v2') && before.endsWith('.zip'), 'bundleUrl follows the fixed filename pattern')
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-v2.zip', 'MOBIUS_TUI_PYTHON_BUNDLE_URL overrides the CDN base and trims trailing slash')
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 Windows shell visibility')
137
- const win = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32')
138
- const linux = reverseConnectArgs('https://mobius.test/', 'tui-linux', 'jwt-test', 'linux')
139
- ok(win.includes('--silent-shell'), 'Windows reverse connection always requests hidden command shells')
140
- ok(!linux.includes('--silent-shell'), 'non-Windows reverse connection does not receive the Windows-only flag')
141
- ok(win[2] === 'https://mobius.test/aimux_bridge', 'reverse connection normalizes the bridge URL')
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.21'"), 'bundle probe rejects stale AIMUX versions')
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()