@mobius-os/mobius 0.3.38 → 0.3.43

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.38",
3
+ "version": "0.3.43",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
@@ -8,21 +8,13 @@
8
8
  "mobius-tui": "bin/mobius-tui.js"
9
9
  },
10
10
  "scripts": {
11
- "dev": "tsx src/main.tsx",
12
- "start": "tsx src/main.tsx",
13
- "typecheck": "tsc --noEmit",
14
- "test:integration": "tsx tests/integration.test.ts",
15
- "test:ui": "tsx tests/ui.test.tsx",
16
- "test:flow": "tsx tests/flow.test.tsx",
17
- "test:resume": "tsx tests/resume.test.tsx",
18
- "test:aimux": "tsx tests/aimux.test.tsx",
19
- "test:reconnect": "tsx tests/reconnect.test.tsx",
20
- "test:screen": "FORCE_COLOR=1 tsx tests/screen.test.tsx",
21
- "test:scroll": "tsx tests/scroll.test.tsx",
22
- "test:viewport": "tsx tests/viewport.test.ts",
23
- "test:selection": "FORCE_COLOR=1 tsx tests/selection.test.tsx",
24
- "test": "npm run typecheck && npm run test:ui && npm run test:integration"
11
+ "start": "tsx src/main.tsx"
25
12
  },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "README.md"
17
+ ],
26
18
  "dependencies": {
27
19
  "chalk": "^5.3.0",
28
20
  "cli-highlight": "2.1.11",
@@ -33,16 +25,7 @@
33
25
  "tsx": "4.19.2",
34
26
  "wrap-ansi": "^9.0.0"
35
27
  },
36
- "devDependencies": {
37
- "@types/node": "18.19.34",
38
- "@types/react": "18.3.3",
39
- "ink-testing-library": "4.0.0",
40
- "typescript": "5.4.5"
41
- },
42
28
  "engines": {
43
29
  "node": ">=18"
44
- },
45
- "publishConfig": {
46
- "access": "public"
47
30
  }
48
31
  }
package/src/aimux.ts CHANGED
@@ -20,6 +20,8 @@ export interface AimuxStatus {
20
20
  state: AimuxState
21
21
  phase?: AimuxPhase
22
22
  detail?: string
23
+ /** Runtime package version when known; falls back to the bundled version. */
24
+ version?: string
23
25
  identifier?: string
24
26
  attempt?: number
25
27
  }
@@ -90,7 +92,9 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
90
92
  // 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
91
93
  // 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
92
94
  const BUNDLE_VER = '3'
93
- const BUNDLE_AIMUX_VERSION = '0.1.23'
95
+ const BUNDLE_AIMUX_VERSION = '0.1.28'
96
+ /** Version expected from the installed or bundled AIMUX runtime. */
97
+ export const AIMUX_VERSION = BUNDLE_AIMUX_VERSION
94
98
  const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
95
99
  const bundlePython = () => WIN
96
100
  ? path.join(bundleDir(), 'python', 'python.exe')
@@ -313,28 +317,64 @@ export function tuiAimuxIdentifier(hostname = os.hostname(), cwd = process.cwd()
313
317
  }
314
318
 
315
319
  /**
316
- * Build the reverse-connect command in one place. The TUI can launch AIMUX
317
- * through either a venv executable or bundled Python; both paths must request
318
- * a fully headless Windows shell or every remote command flashes a console and
319
- * steals keyboard focus from the TUI. Keep the old --silent-shell path
320
- * available for older AIMUX bundles; current bundles use the no-console v2
321
- * implementation (the historical spelling --slient-v2 is intentional).
320
+ * Build the reverse-connect command in one place. On Windows the bridge shells
321
+ * must run headless or every remote command flashes a console and steals
322
+ * keyboard focus from the TUI but *which* flag asks for that depends on the
323
+ * installed aimux: `--silent-shell` landed in 0.1.18, the no-console
324
+ * `--slient-v2`/`--silent-v2` only in 0.1.22+. PyPI and cached bundles in the
325
+ * wild are often older, and hard-coding any one spelling makes Click reject the
326
+ * whole command ("No such option: --slient-v2") and the supervisor crash-loop.
327
+ * So the caller probes `reverse connect --help` once (see probeReverseConnectHelp
328
+ * + pickSilentFlag) and passes the flag aimux actually advertises; when nothing
329
+ * is supported we send nothing rather than crash.
322
330
  */
323
331
  export function reverseConnectArgs(
324
332
  server: string,
325
333
  identifier: string,
326
334
  token: string,
327
335
  platform: NodeJS.Platform = process.platform,
336
+ silentFlag: string | null = null,
328
337
  ): string[] {
329
338
  return [
330
339
  'reverse', 'connect', `${server.replace(/\/$/, '')}/aimux_bridge`,
331
340
  '--identifier', identifier,
332
341
  '--token', token,
333
342
  '--replace',
334
- ...(platform === 'win32' ? ['--slient-v2'] : []),
343
+ ...(platform === 'win32' && silentFlag ? [silentFlag] : []),
335
344
  ]
336
345
  }
337
346
 
347
+ /**
348
+ * Capture `aimux reverse connect --help` so we can see which console-hiding
349
+ * flags this particular build advertises. Returns '' on any failure (the
350
+ * caller then sends no silent flag and stays alive instead of crash-looping).
351
+ */
352
+ export async function probeReverseConnectHelp(launcher: AimuxLauncher): Promise<string> {
353
+ const base = launcher.kind === 'exe'
354
+ ? { cmd: launcher.path, args: ['reverse', 'connect', '--help'] }
355
+ : { cmd: launcher.python, args: ['-m', 'aimux', 'reverse', 'connect', '--help'] }
356
+ try {
357
+ const r = await run(base.cmd, base.args)
358
+ return `${r.stdout}\n${r.stderr}`
359
+ } catch {
360
+ return ''
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Pick the strongest console-hiding flag aimux advertised for Windows. Prefers
366
+ * the correctly-spelled --silent-v2 (future-proof if the historical --slient-v2
367
+ * typo alias is ever dropped), then the --slient-v2 alias, then --silent-shell.
368
+ * Returns null off-Windows or when the installed aimux supports none.
369
+ */
370
+ export function pickSilentFlag(helpText: string, platform: NodeJS.Platform = process.platform): string | null {
371
+ if (platform !== 'win32') return null
372
+ if (/--silent-v2\b/.test(helpText)) return '--silent-v2'
373
+ if (/--slient-v2\b/.test(helpText)) return '--slient-v2'
374
+ if (/--silent-shell\b/.test(helpText)) return '--silent-shell'
375
+ return null
376
+ }
377
+
338
378
  export async function probeAimuxBridgeConnection(
339
379
  server: string,
340
380
  token: string,
@@ -513,6 +553,10 @@ export class AimuxSupervisor {
513
553
 
514
554
  let supervisor: AimuxSupervisor | null = null
515
555
  let installing: Promise<void> | null = null
556
+ // Resolved Windows console-hiding flag for the installed aimux (undefined =
557
+ // not probed yet this process). Cached so reconnects reuse it without re-running
558
+ // `aimux reverse connect --help`. See reverseConnectArgs for why this is probed.
559
+ let cachedSilentFlag: string | null | undefined = undefined
516
560
 
517
561
  export async function startAimuxConnection(opts: { server: string; token: string; onStatus?: (s: AimuxStatus) => void }): Promise<void> {
518
562
  const onStatus = opts.onStatus ?? (() => {})
@@ -534,9 +578,18 @@ export async function startAimuxConnection(opts: { server: string; token: string
534
578
  if (!ready.ok || !ready.launcher) { logInstall(`startAimuxConnection giving up: ${ready.error}\n`); onStatus({ state: 'failed', phase: 'idle', detail: `${ready.error} · 日志: ${aimuxLogPath()}` }); return }
535
579
  const identifier = tuiAimuxIdentifier()
536
580
  const launcher = ready.launcher
581
+ // Windows only: ask the installed aimux which console-hiding flag it accepts
582
+ // before spawning, so a version mismatch (older PyPI/bundle aimux without
583
+ // --slient-v2) can't crash-loop the supervisor with "No such option".
584
+ if (WIN && cachedSilentFlag === undefined) {
585
+ const help = await probeReverseConnectHelp(launcher)
586
+ cachedSilentFlag = pickSilentFlag(help)
587
+ logInstall(`reverse-connect silent flag probe → ${cachedSilentFlag ?? '(none supported; sending no flag)'}\n`)
588
+ }
589
+ const silentFlag = cachedSilentFlag
537
590
  supervisor = new AimuxSupervisor({
538
591
  server: opts.server, token: opts.token, identifier, onStatus,
539
- spawnProcess: () => spawnLauncher(launcher, reverseConnectArgs(opts.server, identifier, opts.token)),
592
+ spawnProcess: () => spawnLauncher(launcher, reverseConnectArgs(opts.server, identifier, opts.token, process.platform, silentFlag)),
540
593
  })
541
594
  supervisor.start()
542
595
  })().finally(() => { installing = null })
@@ -24,11 +24,12 @@ import {
24
24
  import type { ReadyState } from './PrepScreen.js'
25
25
  import type { AnyEntry } from '../types.js'
26
26
  import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
27
- import type { AimuxStatus } from '../aimux.js'
27
+ import { AIMUX_VERSION, type AimuxStatus } from '../aimux.js'
28
28
  import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
29
29
  import { isEscapeKeypress, isMouseInput, useMouseEvents, useStableInput } from './primitives.js'
30
30
  import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary, previousWordBoundary, nextWordBoundary } from '../lib/delete-keys.js'
31
31
  import { useCursorKeyCapture } from '../lib/cursor-keys.js'
32
+ import { usePaintFlushOnInput } from '../lib/paint-flush.js'
32
33
 
33
34
  interface ChatProps {
34
35
  client: MobiusClient
@@ -56,11 +57,13 @@ const STATUS_ROWS = 3
56
57
 
57
58
  const SLASH_COMMANDS = [
58
59
  { cmd: '/clear', desc: '清空当前对话,开启新会话' },
60
+ { cmd: '/compact', desc: '压缩当前会话上下文' },
59
61
  { cmd: '/resume', desc: '恢复一个历史会话' },
60
62
  { cmd: '/model', desc: '更换模型并开启新会话(保留当前任务)' },
61
63
  { cmd: '/config', desc: '重新选择项目、任务和模型' },
62
64
  { cmd: '/logout', desc: '断开当前连接并返回登录界面' },
63
65
  { cmd: '/help', desc: '显示帮助' },
66
+ { cmd: '/version', desc: '显示 TUI、AIMUX 和运行环境版本' },
64
67
  { cmd: '/quit', desc: '退出 TUI' },
65
68
  ]
66
69
 
@@ -74,6 +77,9 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
74
77
  const [modelLabel, setModelLabel] = useState<string | null>(null)
75
78
  const [configOpen, setConfigOpen] = useState(false)
76
79
  const [reconfigOpen, setReconfigOpen] = useState(false)
80
+ // 本地命令错误 (如无会话时 /compact): 与 chat.error 分开, 下一次提交时清除。
81
+ const [slashError, setSlashError] = useState<string | null>(null)
82
+ const [versionInfo, setVersionInfo] = useState<string[] | null>(null)
77
83
  // Ink may deliver one final event to Composer while an async config picker is
78
84
  // replacing it. The shared ref lets that stale listener report "not handled"
79
85
  // so App can replay the key after the new Select mounts.
@@ -85,29 +91,61 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
85
91
  const handlerRef = useRef<{ configOpen: boolean; reconfigOpen: boolean; sessionId: string | null }>({ configOpen: false, reconfigOpen: false, sessionId: null })
86
92
  handlerRef.current = { configOpen, reconfigOpen, sessionId: chat.sessionId }
87
93
  const terminal = useTerminalSize()
94
+ // Paint each keystroke immediately instead of waiting out Ink's 32ms render
95
+ // throttle — that dead window was the typing lag users felt.
96
+ usePaintFlushOnInput()
88
97
 
89
98
  const runSlash = useCallback((raw: string) => {
90
99
  const [name] = raw.trim().split(/\s+/)
91
100
  switch (name) {
92
101
  case '/clear': onClear(); return true
102
+ case '/compact': {
103
+ // 对齐 web sendCompactCommand: 把字面 '/compact' 作为消息发给后端, 由
104
+ // agent (claude-code 原生 slash command / codex) 自行执行上下文压缩。
105
+ // 尚无会话时没有可压缩的上文, 提示而不是新建空会话去发。
106
+ if (!chat.sessionId) {
107
+ setSlashError('当前没有可发送指令的会话')
108
+ return true
109
+ }
110
+ setShowHelp(false)
111
+ setRowAnchor(null)
112
+ void chat.send('/compact')
113
+ return true
114
+ }
93
115
  case '/resume': onResume(); return true
94
116
  case '/help': setShowHelp(s => !s); return true
117
+ case '/version': {
118
+ const details = [
119
+ `TUI v${TUI_VERSION}`,
120
+ `AIMUX v${aimuxStatus?.version ?? AIMUX_VERSION}`,
121
+ `Node ${process.version}`,
122
+ `平台 ${process.platform}/${process.arch}`,
123
+ `服务器 ${client.server}`,
124
+ `模型 ${modelLabel ?? ready.prefs.model ?? 'default'}`,
125
+ `AIMUX状态 ${aimuxStatus ? aimuxStatusText(aimuxStatus, true) : '未知'}`,
126
+ ]
127
+ setVersionInfo(details)
128
+ setShowHelp(false)
129
+ return true
130
+ }
95
131
  case '/model': setConfigOpen(true); return true
96
132
  case '/config': setReconfigOpen(true); return true
97
133
  case '/logout': onLogout(); return true
98
134
  case '/quit': case '/exit': onQuit(); return true
99
135
  default: return false
100
136
  }
101
- }, [onClear, onResume, onQuit, onLogout])
137
+ }, [aimuxStatus, chat, client.server, modelLabel, ready.prefs.model, onClear, onResume, onQuit, onLogout])
102
138
 
103
139
  const onSubmit = useCallback((text: string) => {
104
140
  const t = text.trim()
105
141
  if (!t) return
142
+ setSlashError(null)
106
143
  if (t.startsWith('/')) {
107
144
  if (!runSlash(t)) setShowHelp(true)
108
145
  return
109
146
  }
110
147
  setShowHelp(false)
148
+ setVersionInfo(null)
111
149
  setRowAnchor(null)
112
150
  void chat.send(t)
113
151
  }, [chat, runSlash])
@@ -160,7 +198,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
160
198
  (entry) => rowsForEntry(entry),
161
199
  ), [transcriptEntries, rowsForEntry])
162
200
  const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
163
- const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
201
+ const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0) + (slashError ? 1 : 0) + (versionInfo ? versionInfo.length + 2 : 0)
164
202
  const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
165
203
  // Conversation chrome is exactly two rows: compact header + navigation.
166
204
  const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 2)
@@ -214,9 +252,10 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
214
252
  else if (key.pageDown) scrollRows(pageRows)
215
253
  }, { interactive: false })
216
254
 
217
- const navigationHint = viewport.hasOlder
218
- ? `${viewport.hasNewer ? '↑ 较早内容 · ↓ 较新内容' : '↑ 还有较早内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
219
- : `${viewport.hasNewer ? '已到最早 · ↓ 还有较新内容' : '全部内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
255
+ const navigationPosition = viewport.hasOlder
256
+ ? (viewport.hasNewer ? '↑ 较早内容 · ' : '↑ 还有较早内容')
257
+ : (viewport.hasNewer ? '已到最早 · ' : '全部内容')
258
+ const navigationDetail = ` · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
220
259
 
221
260
  // Mouse: wheel pages through history in small fixed steps, and a left-button
222
261
  // drag selects transcript text (tmux-style: the app owns the mouse, draws its
@@ -335,7 +374,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
335
374
  : <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
336
375
 
337
376
  {!showWelcome
338
- ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {navigationHint}</Text></Box>
377
+ ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {navigationPosition}{viewport.hasNewer ? <Text color="yellowBright">↓ 有新内容</Text> : null}{navigationDetail}</Text></Box>
339
378
  : null}
340
379
 
341
380
  <Box
@@ -365,6 +404,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
365
404
  <Box flexDirection="column" flexShrink={0}>
366
405
  {chat.typing ? <WorkingIndicator firstQuery={firstQueryInFlight} /> : null}
367
406
  {chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
407
+ {slashError ? <Text color="red">⚠ {slashError}</Text> : null}
408
+ {versionInfo ? <VersionBlock lines={versionInfo} /> : null}
368
409
 
369
410
  <Composer
370
411
  onSubmit={onSubmit}
@@ -557,6 +598,15 @@ function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] })
557
598
  )
558
599
  }
559
600
 
601
+ function VersionBlock({ lines }: { lines: string[] }) {
602
+ return (
603
+ <Box flexDirection="column" borderStyle="round" borderColor="gray" borderDimColor paddingX={1} marginTop={1}>
604
+ <Text bold color="cyan">Mobius 版本信息</Text>
605
+ {lines.map(line => <Text key={line} dimColor>{line}</Text>)}
606
+ </Box>
607
+ )
608
+ }
609
+
560
610
  interface ComposerProps {
561
611
  onSubmit: (text: string) => void
562
612
  onStop: () => void
@@ -574,6 +624,11 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
574
624
  const [popupDismissed, setPopupDismissed] = useState(false)
575
625
  const historyRef = useRef<string[]>([])
576
626
  const [histIdx, setHistIdx] = useState<number | null>(null)
627
+ // Idle Ctrl+C quits, but a single accidental press must not kill the session:
628
+ // the first press arms a 2s confirmation window, the second press exits.
629
+ const [confirmQuit, setConfirmQuit] = useState(false)
630
+ const confirmQuitRef = useRef(false)
631
+ const confirmQuitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
577
632
  const valueRef = useRef(value)
578
633
  const cursorRef = useRef(cursor)
579
634
  const pasteRef = useRef<ComposerPasteState>({
@@ -587,6 +642,8 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
587
642
  })
588
643
  const { stdout } = useStdout()
589
644
 
645
+ useEffect(() => () => { if (confirmQuitTimer.current) clearTimeout(confirmQuitTimer.current) }, [])
646
+
590
647
  // Physical Backspace/Delete keys are owned by useDeleteKeyCapture from the
591
648
  // raw stdin bytes — Ink reports the Backspace key (\x7f) and the Delete key
592
649
  // (ESC[3~) both as `key.delete`, so handling `key.delete` in useInput would
@@ -784,7 +841,19 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
784
841
  }
785
842
  return
786
843
  }
787
- if (key.ctrl && input === 'c') { typing ? void onStop() : onQuit(); return }
844
+ if (key.ctrl && input === 'c') {
845
+ if (typing) { void onStop(); return }
846
+ // Idle Ctrl+C exits, but guard against an accidental single press.
847
+ if (confirmQuitRef.current) { onQuit(); return }
848
+ confirmQuitRef.current = true
849
+ setConfirmQuit(true)
850
+ if (confirmQuitTimer.current) clearTimeout(confirmQuitTimer.current)
851
+ confirmQuitTimer.current = setTimeout(() => {
852
+ confirmQuitRef.current = false
853
+ setConfirmQuit(false)
854
+ }, 2000)
855
+ return
856
+ }
788
857
  // Physical Backspace/Delete keys are handled by useDeleteKeyCapture above
789
858
  // (raw stdin bytes distinguish them; Ink maps both to `key.delete`). Only
790
859
  // the unambiguous logical editing bindings stay here.
@@ -899,7 +968,9 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
899
968
  })}
900
969
  </Box>
901
970
  <Box justifyContent="space-between">
902
- <Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>
971
+ {confirmQuit
972
+ ? <Text color="yellowBright" bold>请再次按下Ctrl+C退出</Text>
973
+ : <Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>}
903
974
  <Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
904
975
  </Box>
905
976
  </Box>
@@ -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
  }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Typing-latency optimizations against Ink 5.2's fixed render pipeline.
3
+ *
4
+ * Profiling a keystroke loop (node --cpu-prof) showed ~40% of typing time
5
+ * inside Ink's per-paint `Output.get()`:
6
+ * stringWidth 13.5% + styledCharsFromTokens 12.4% + Output.get 6.4%
7
+ * + styledCharsToString 5.5% + diffAnsiCodes 3.4%
8
+ *
9
+ * Every paint rebuilds the whole frame: for each write-op line it applies
10
+ * style transformers, tokenizes the ANSI-styled text into styled chars and
11
+ * re-measures widths, then walks the full cell matrix back to a string.
12
+ * While typing, 19 of 22 line strings are byte-identical across paints (only
13
+ * the composer row changes), yet all are re-tokenized every time — that
14
+ * repetition is the lag users feel.
15
+ *
16
+ * Two runtime patches below. Both verify Ink internals first and silently
17
+ * no-op on mismatch (or when MOBIUS_TUI_DISABLE_PAINT_FLUSH=1):
18
+ *
19
+ * 1. usePaintFlushOnInput — Ink hardcodes `throttle(onRender, 32ms)`. React
20
+ * commits a key in <1ms; the paint can then wait out the throttle window.
21
+ * On each editing key we call the throttle's `flush()` right after the
22
+ * commit lands, so the frame paints in the same event-loop turn.
23
+ *
24
+ * 2. installStyledLineCache — replaces `Output.prototype.get` with a faithful
25
+ * reimplementation whose only difference is memoizing
26
+ * `styledCharsFromTokens(tokenize(line))` by the post-transform line
27
+ * string (the exact input tokenize receives, so cached results are
28
+ * byte-identical). Unchanged lines skip the tokenize+measure hot path,
29
+ * which is where the profiled ~40% lives.
30
+ */
31
+
32
+ import { useEffect } from 'react'
33
+ import { useStdin, useStdout } from 'ink'
34
+
35
+ // ── 1. flush the render throttle right after each editing key ────────────────
36
+
37
+ type Flushable = { flush?: () => void }
38
+
39
+ /** Text-editing inputs that should paint immediately (typed text, Enter, backspace). */
40
+ function isEditingInput(input: string): boolean {
41
+ if (!input) return false
42
+ if (input === '\r' || input === '\n') return true
43
+ if (input === '\x7f' || input === '\x08') return true
44
+ // Anything without an ESC lead is typed text (or a paste chunk). Sequences
45
+ // (arrows, mouse, bracketed paste) start with ESC and keep normal pacing.
46
+ return !input.startsWith('\x1b')
47
+ }
48
+
49
+ export function usePaintFlushOnInput(): void {
50
+ const { internal_eventEmitter } = useStdin()
51
+ const { stdout } = useStdout()
52
+
53
+ useEffect(() => {
54
+ if (process.env.MOBIUS_TUI_DISABLE_PAINT_FLUSH === '1') return
55
+ if (!internal_eventEmitter) return
56
+ installStyledLineCache()
57
+ const handler = (chunk: unknown) => {
58
+ if (!isEditingInput(String(chunk))) return
59
+ // React schedules the commit for this key on a setImmediate (scheduler's
60
+ // Immediate priority). Flushing on a microtask would paint the STALE
61
+ // frame before that commit lands. Schedule the flush for after the
62
+ // commit: another setImmediate queued from here runs after the one
63
+ // React already queued (FIFO within the same iteration).
64
+ setImmediate(() => {
65
+ try { getThrottledRender(stdout)?.flush?.() } catch { /* best-effort */ }
66
+ })
67
+ }
68
+ internal_eventEmitter.on('input', handler)
69
+ return () => { internal_eventEmitter.off('input', handler) }
70
+ }, [internal_eventEmitter, stdout])
71
+ }
72
+
73
+ // ── Ink internals access ─────────────────────────────────────────────────────
74
+ // Ink's exports map blocks subpath imports ('ink/build/instances.js' →
75
+ // ERR_PACKAGE_PATH_NOT_EXPORTED), but `import.meta.resolve('ink')` yields the
76
+ // package entry file URL and the internal modules live next to it in the same
77
+ // build directory. Resolving relative to the entry keeps this working in any
78
+ // install layout (local node_modules, global npm prefix).
79
+
80
+ function inkModuleUrl(name: string): string {
81
+ return new URL(name, new URL('.', (import.meta as any).resolve('ink') as string)).href
82
+ }
83
+
84
+ let cachedInstances: WeakMap<object, any> | null | undefined
85
+
86
+ function getThrottledRender(stdout: object): Flushable | null {
87
+ if (cachedInstances === undefined) {
88
+ cachedInstances = null
89
+ void import(inkModuleUrl("instances.js"))
90
+ .then(mod => { cachedInstances = mod.default })
91
+ .catch(() => { /* keep null */ })
92
+ }
93
+ try {
94
+ return cachedInstances?.get(stdout)?.rootNode?.onRender ?? null
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ // ── 2. memoize styled-line tokenization across paints ────────────────────────
101
+
102
+ let cacheInstalled = false
103
+
104
+ export function installStyledLineCache(): void {
105
+ if (cacheInstalled || process.env.MOBIUS_TUI_DISABLE_PAINT_FLUSH === '1') return
106
+ cacheInstalled = true
107
+ void (async () => {
108
+ try {
109
+ const [outputMod, at, widestLineMod, stringWidthMod, sliceAnsiMod]: any[] = await Promise.all([
110
+ import(inkModuleUrl("output.js")),
111
+ import('@alcalzone/ansi-tokenize'),
112
+ import('widest-line'),
113
+ import('string-width'),
114
+ import('slice-ansi'),
115
+ ])
116
+ const OutputClass = outputMod.default
117
+ const proto = OutputClass?.prototype
118
+ if (!proto || typeof proto.get !== 'function' || typeof proto.write !== 'function') return
119
+ if (typeof at.styledCharsFromTokens !== 'function' || typeof at.tokenize !== 'function') return
120
+ const widestLine = widestLineMod.default
121
+ const stringWidth = stringWidthMod.default
122
+ const sliceAnsi = sliceAnsiMod.default
123
+
124
+ // Fragile-internals guard: confirm the op shape this Ink version writes.
125
+ const probe = new OutputClass({ width: 4, height: 2 })
126
+ probe.write(0, 0, 'ab', { transformers: [] })
127
+ const op = probe.operations?.[0]
128
+ if (!op || op.type !== 'write' || typeof op.text !== 'string' || !Array.isArray(op.transformers)) return
129
+
130
+ const cache = new Map<string, any[]>()
131
+ const styledCharsOf = (line: string): any[] => {
132
+ const hit = cache.get(line)
133
+ if (hit) return hit
134
+ const chars = at.styledCharsFromTokens(at.tokenize(line)) as any[]
135
+ if (cache.size >= 4096) cache.clear() // bound memory; rebuilt lazily
136
+ cache.set(line, chars)
137
+ return chars
138
+ }
139
+
140
+ const origGet = proto.get
141
+ proto.get = function (this: any) {
142
+ // Faithful reimplementation of Ink 5.2.0 Output.get with one change:
143
+ // the tokenize+styledCharsFromTokens result is memoized by the
144
+ // post-transform line string. Everything else mirrors upstream.
145
+ const output = []
146
+ for (let y = 0; y < this.height; y++) {
147
+ const row = []
148
+ for (let x = 0; x < this.width; x++) {
149
+ row.push({ type: 'char', value: ' ', fullWidth: false, styles: [] })
150
+ }
151
+ output.push(row)
152
+ }
153
+ const clips: any[] = []
154
+ for (const operation of this.operations) {
155
+ if (operation.type === 'clip') clips.push(operation.clip)
156
+ if (operation.type === 'unclip') clips.pop()
157
+ if (operation.type !== 'write') continue
158
+ const { text, transformers } = operation
159
+ let { x, y } = operation
160
+ let lines = text.split('\n')
161
+ const clip = clips.at(-1)
162
+ if (clip) {
163
+ const clipHorizontally = typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'
164
+ const clipVertically = typeof clip?.y1 === 'number' && typeof clip?.y2 === 'number'
165
+ if (clipHorizontally) {
166
+ const width = widestLine(text)
167
+ if (x + width < clip.x1 || x > clip.x2) continue
168
+ }
169
+ if (clipVertically) {
170
+ const height = lines.length
171
+ if (y + height < clip.y1 || y > clip.y2) continue
172
+ }
173
+ if (clipHorizontally) {
174
+ lines = lines.map((line: string) => {
175
+ const from = x < clip.x1 ? clip.x1 - x : 0
176
+ const width = stringWidth(line)
177
+ const to = x + width > clip.x2 ? clip.x2 - x : width
178
+ return sliceAnsi(line, from, to)
179
+ })
180
+ if (x < clip.x1) x = clip.x1
181
+ }
182
+ if (clipVertically) {
183
+ const from = y < clip.y1 ? clip.y1 - y : 0
184
+ const height = lines.length
185
+ const to = y + height > clip.y2 ? clip.y2 - y : height
186
+ lines = lines.slice(from, to)
187
+ if (y < clip.y1) y = clip.y1
188
+ }
189
+ }
190
+ let offsetY = 0
191
+ for (const [index, line0] of lines.entries()) {
192
+ const currentLine = output[y + offsetY]
193
+ if (!currentLine) continue
194
+ let line = line0
195
+ for (const transformer of transformers) line = transformer(line, index)
196
+ const characters = styledCharsOf(line)
197
+ let offsetX = x
198
+ for (const character of characters) {
199
+ currentLine[offsetX] = character
200
+ const isWideCharacter = character.fullWidth || character.value.length > 1
201
+ if (isWideCharacter) {
202
+ currentLine[offsetX + 1] = {
203
+ type: 'char', value: '', fullWidth: false, styles: character.styles,
204
+ }
205
+ }
206
+ offsetX += isWideCharacter ? 2 : 1
207
+ }
208
+ offsetY++
209
+ }
210
+ }
211
+ const generatedOutput = output
212
+ .map(line => {
213
+ const lineWithoutEmptyItems = line.filter((item: unknown) => item !== undefined)
214
+ return at.styledCharsToString(lineWithoutEmptyItems).trimEnd()
215
+ })
216
+ .join('\n')
217
+ return { output: generatedOutput, height: output.length }
218
+ }
219
+ // Keep a handle for tests/debugging; origGet unused beyond the guard.
220
+ void origGet
221
+ } catch { /* keep stock behavior */ }
222
+ })()
223
+ }