@mobius-os/mobius 0.3.38 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.38",
3
+ "version": "0.3.42",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
package/src/aimux.ts CHANGED
@@ -313,28 +313,64 @@ export function tuiAimuxIdentifier(hostname = os.hostname(), cwd = process.cwd()
313
313
  }
314
314
 
315
315
  /**
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).
316
+ * Build the reverse-connect command in one place. On Windows the bridge shells
317
+ * must run headless or every remote command flashes a console and steals
318
+ * keyboard focus from the TUI but *which* flag asks for that depends on the
319
+ * installed aimux: `--silent-shell` landed in 0.1.18, the no-console
320
+ * `--slient-v2`/`--silent-v2` only in 0.1.22+. PyPI and cached bundles in the
321
+ * wild are often older, and hard-coding any one spelling makes Click reject the
322
+ * whole command ("No such option: --slient-v2") and the supervisor crash-loop.
323
+ * So the caller probes `reverse connect --help` once (see probeReverseConnectHelp
324
+ * + pickSilentFlag) and passes the flag aimux actually advertises; when nothing
325
+ * is supported we send nothing rather than crash.
322
326
  */
323
327
  export function reverseConnectArgs(
324
328
  server: string,
325
329
  identifier: string,
326
330
  token: string,
327
331
  platform: NodeJS.Platform = process.platform,
332
+ silentFlag: string | null = null,
328
333
  ): string[] {
329
334
  return [
330
335
  'reverse', 'connect', `${server.replace(/\/$/, '')}/aimux_bridge`,
331
336
  '--identifier', identifier,
332
337
  '--token', token,
333
338
  '--replace',
334
- ...(platform === 'win32' ? ['--slient-v2'] : []),
339
+ ...(platform === 'win32' && silentFlag ? [silentFlag] : []),
335
340
  ]
336
341
  }
337
342
 
343
+ /**
344
+ * Capture `aimux reverse connect --help` so we can see which console-hiding
345
+ * flags this particular build advertises. Returns '' on any failure (the
346
+ * caller then sends no silent flag and stays alive instead of crash-looping).
347
+ */
348
+ export async function probeReverseConnectHelp(launcher: AimuxLauncher): Promise<string> {
349
+ const base = launcher.kind === 'exe'
350
+ ? { cmd: launcher.path, args: ['reverse', 'connect', '--help'] }
351
+ : { cmd: launcher.python, args: ['-m', 'aimux', 'reverse', 'connect', '--help'] }
352
+ try {
353
+ const r = await run(base.cmd, base.args)
354
+ return `${r.stdout}\n${r.stderr}`
355
+ } catch {
356
+ return ''
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Pick the strongest console-hiding flag aimux advertised for Windows. Prefers
362
+ * the correctly-spelled --silent-v2 (future-proof if the historical --slient-v2
363
+ * typo alias is ever dropped), then the --slient-v2 alias, then --silent-shell.
364
+ * Returns null off-Windows or when the installed aimux supports none.
365
+ */
366
+ export function pickSilentFlag(helpText: string, platform: NodeJS.Platform = process.platform): string | null {
367
+ if (platform !== 'win32') return null
368
+ if (/--silent-v2\b/.test(helpText)) return '--silent-v2'
369
+ if (/--slient-v2\b/.test(helpText)) return '--slient-v2'
370
+ if (/--silent-shell\b/.test(helpText)) return '--silent-shell'
371
+ return null
372
+ }
373
+
338
374
  export async function probeAimuxBridgeConnection(
339
375
  server: string,
340
376
  token: string,
@@ -513,6 +549,10 @@ export class AimuxSupervisor {
513
549
 
514
550
  let supervisor: AimuxSupervisor | null = null
515
551
  let installing: Promise<void> | null = null
552
+ // Resolved Windows console-hiding flag for the installed aimux (undefined =
553
+ // not probed yet this process). Cached so reconnects reuse it without re-running
554
+ // `aimux reverse connect --help`. See reverseConnectArgs for why this is probed.
555
+ let cachedSilentFlag: string | null | undefined = undefined
516
556
 
517
557
  export async function startAimuxConnection(opts: { server: string; token: string; onStatus?: (s: AimuxStatus) => void }): Promise<void> {
518
558
  const onStatus = opts.onStatus ?? (() => {})
@@ -534,9 +574,18 @@ export async function startAimuxConnection(opts: { server: string; token: string
534
574
  if (!ready.ok || !ready.launcher) { logInstall(`startAimuxConnection giving up: ${ready.error}\n`); onStatus({ state: 'failed', phase: 'idle', detail: `${ready.error} · 日志: ${aimuxLogPath()}` }); return }
535
575
  const identifier = tuiAimuxIdentifier()
536
576
  const launcher = ready.launcher
577
+ // Windows only: ask the installed aimux which console-hiding flag it accepts
578
+ // before spawning, so a version mismatch (older PyPI/bundle aimux without
579
+ // --slient-v2) can't crash-loop the supervisor with "No such option".
580
+ if (WIN && cachedSilentFlag === undefined) {
581
+ const help = await probeReverseConnectHelp(launcher)
582
+ cachedSilentFlag = pickSilentFlag(help)
583
+ logInstall(`reverse-connect silent flag probe → ${cachedSilentFlag ?? '(none supported; sending no flag)'}\n`)
584
+ }
585
+ const silentFlag = cachedSilentFlag
537
586
  supervisor = new AimuxSupervisor({
538
587
  server: opts.server, token: opts.token, identifier, onStatus,
539
- spawnProcess: () => spawnLauncher(launcher, reverseConnectArgs(opts.server, identifier, opts.token)),
588
+ spawnProcess: () => spawnLauncher(launcher, reverseConnectArgs(opts.server, identifier, opts.token, process.platform, silentFlag)),
540
589
  })
541
590
  supervisor.start()
542
591
  })().finally(() => { installing = null })
@@ -56,6 +56,7 @@ const STATUS_ROWS = 3
56
56
 
57
57
  const SLASH_COMMANDS = [
58
58
  { cmd: '/clear', desc: '清空当前对话,开启新会话' },
59
+ { cmd: '/compact', desc: '压缩当前会话上下文' },
59
60
  { cmd: '/resume', desc: '恢复一个历史会话' },
60
61
  { cmd: '/model', desc: '更换模型并开启新会话(保留当前任务)' },
61
62
  { cmd: '/config', desc: '重新选择项目、任务和模型' },
@@ -74,6 +75,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
74
75
  const [modelLabel, setModelLabel] = useState<string | null>(null)
75
76
  const [configOpen, setConfigOpen] = useState(false)
76
77
  const [reconfigOpen, setReconfigOpen] = useState(false)
78
+ // 本地命令错误 (如无会话时 /compact): 与 chat.error 分开, 下一次提交时清除。
79
+ const [slashError, setSlashError] = useState<string | null>(null)
77
80
  // Ink may deliver one final event to Composer while an async config picker is
78
81
  // replacing it. The shared ref lets that stale listener report "not handled"
79
82
  // so App can replay the key after the new Select mounts.
@@ -90,6 +93,19 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
90
93
  const [name] = raw.trim().split(/\s+/)
91
94
  switch (name) {
92
95
  case '/clear': onClear(); return true
96
+ case '/compact': {
97
+ // 对齐 web sendCompactCommand: 把字面 '/compact' 作为消息发给后端, 由
98
+ // agent (claude-code 原生 slash command / codex) 自行执行上下文压缩。
99
+ // 尚无会话时没有可压缩的上文, 提示而不是新建空会话去发。
100
+ if (!chat.sessionId) {
101
+ setSlashError('当前没有可发送指令的会话')
102
+ return true
103
+ }
104
+ setShowHelp(false)
105
+ setRowAnchor(null)
106
+ void chat.send('/compact')
107
+ return true
108
+ }
93
109
  case '/resume': onResume(); return true
94
110
  case '/help': setShowHelp(s => !s); return true
95
111
  case '/model': setConfigOpen(true); return true
@@ -98,11 +114,12 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
98
114
  case '/quit': case '/exit': onQuit(); return true
99
115
  default: return false
100
116
  }
101
- }, [onClear, onResume, onQuit, onLogout])
117
+ }, [chat, onClear, onResume, onQuit, onLogout])
102
118
 
103
119
  const onSubmit = useCallback((text: string) => {
104
120
  const t = text.trim()
105
121
  if (!t) return
122
+ setSlashError(null)
106
123
  if (t.startsWith('/')) {
107
124
  if (!runSlash(t)) setShowHelp(true)
108
125
  return
@@ -160,7 +177,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
160
177
  (entry) => rowsForEntry(entry),
161
178
  ), [transcriptEntries, rowsForEntry])
162
179
  const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
163
- const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
180
+ const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0) + (slashError ? 1 : 0)
164
181
  const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
165
182
  // Conversation chrome is exactly two rows: compact header + navigation.
166
183
  const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 2)
@@ -214,9 +231,10 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
214
231
  else if (key.pageDown) scrollRows(pageRows)
215
232
  }, { interactive: false })
216
233
 
217
- const navigationHint = viewport.hasOlder
218
- ? `${viewport.hasNewer ? '↑ 较早内容 · ↓ 较新内容' : '↑ 还有较早内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
219
- : `${viewport.hasNewer ? '已到最早 · ↓ 还有较新内容' : '全部内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
234
+ const navigationPosition = viewport.hasOlder
235
+ ? (viewport.hasNewer ? '↑ 较早内容 · ' : '↑ 还有较早内容')
236
+ : (viewport.hasNewer ? '已到最早 · ' : '全部内容')
237
+ const navigationDetail = ` · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
220
238
 
221
239
  // Mouse: wheel pages through history in small fixed steps, and a left-button
222
240
  // drag selects transcript text (tmux-style: the app owns the mouse, draws its
@@ -335,7 +353,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
335
353
  : <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
336
354
 
337
355
  {!showWelcome
338
- ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {navigationHint}</Text></Box>
356
+ ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {navigationPosition}{viewport.hasNewer ? <Text color="yellowBright">↓ 有新内容</Text> : null}{navigationDetail}</Text></Box>
339
357
  : null}
340
358
 
341
359
  <Box
@@ -365,6 +383,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
365
383
  <Box flexDirection="column" flexShrink={0}>
366
384
  {chat.typing ? <WorkingIndicator firstQuery={firstQueryInFlight} /> : null}
367
385
  {chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
386
+ {slashError ? <Text color="red">⚠ {slashError}</Text> : null}
368
387
 
369
388
  <Composer
370
389
  onSubmit={onSubmit}
@@ -574,6 +593,11 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
574
593
  const [popupDismissed, setPopupDismissed] = useState(false)
575
594
  const historyRef = useRef<string[]>([])
576
595
  const [histIdx, setHistIdx] = useState<number | null>(null)
596
+ // Idle Ctrl+C quits, but a single accidental press must not kill the session:
597
+ // the first press arms a 2s confirmation window, the second press exits.
598
+ const [confirmQuit, setConfirmQuit] = useState(false)
599
+ const confirmQuitRef = useRef(false)
600
+ const confirmQuitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
577
601
  const valueRef = useRef(value)
578
602
  const cursorRef = useRef(cursor)
579
603
  const pasteRef = useRef<ComposerPasteState>({
@@ -587,6 +611,8 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
587
611
  })
588
612
  const { stdout } = useStdout()
589
613
 
614
+ useEffect(() => () => { if (confirmQuitTimer.current) clearTimeout(confirmQuitTimer.current) }, [])
615
+
590
616
  // Physical Backspace/Delete keys are owned by useDeleteKeyCapture from the
591
617
  // raw stdin bytes — Ink reports the Backspace key (\x7f) and the Delete key
592
618
  // (ESC[3~) both as `key.delete`, so handling `key.delete` in useInput would
@@ -784,7 +810,19 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
784
810
  }
785
811
  return
786
812
  }
787
- if (key.ctrl && input === 'c') { typing ? void onStop() : onQuit(); return }
813
+ if (key.ctrl && input === 'c') {
814
+ if (typing) { void onStop(); return }
815
+ // Idle Ctrl+C exits, but guard against an accidental single press.
816
+ if (confirmQuitRef.current) { onQuit(); return }
817
+ confirmQuitRef.current = true
818
+ setConfirmQuit(true)
819
+ if (confirmQuitTimer.current) clearTimeout(confirmQuitTimer.current)
820
+ confirmQuitTimer.current = setTimeout(() => {
821
+ confirmQuitRef.current = false
822
+ setConfirmQuit(false)
823
+ }, 2000)
824
+ return
825
+ }
788
826
  // Physical Backspace/Delete keys are handled by useDeleteKeyCapture above
789
827
  // (raw stdin bytes distinguish them; Ink maps both to `key.delete`). Only
790
828
  // the unambiguous logical editing bindings stay here.
@@ -899,7 +937,9 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
899
937
  })}
900
938
  </Box>
901
939
  <Box justifyContent="space-between">
902
- <Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>
940
+ {confirmQuit
941
+ ? <Text color="yellowBright" bold>请再次按下Ctrl+C退出</Text>
942
+ : <Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>}
903
943
  <Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
904
944
  </Box>
905
945
  </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
  }
@@ -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, tuiAimuxIdentifier } 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
@@ -133,12 +133,31 @@ 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('--slient-v2'), 'Windows reverse connection always requests the no-console shell mode')
140
- ok(!linux.includes('--slient-v2'), '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')
142
161
  }
143
162
 
144
163
  function testAimuxIdentifierScopesWorkspace() {
@@ -209,6 +228,7 @@ async function main() {
209
228
  await testBundleArchAndUrl()
210
229
  await testSpawnLauncher()
211
230
  testReverseConnectArgs()
231
+ testPickSilentFlag()
212
232
  testAimuxIdentifierScopesWorkspace()
213
233
  testBundleHealthCheck()
214
234
  await testEnsureFromBundleReady()
@@ -35,6 +35,7 @@ function ok(c: boolean, m: string) { c ? (pass++, console.log(` ✓ ${m}`)) : (
35
35
 
36
36
  // ── mocked backend (precise URL matchers — substring overlaps broke an earlier draft) ─
37
37
  const PID = 'proj-1', IID = 'issue-1', SID = 'sess-1'
38
+ let lastMessageBody: any = null
38
39
  function mockFetch(url: string, init?: RequestInit): Response {
39
40
  // SSE
40
41
  if (url.includes('/events')) {
@@ -56,6 +57,18 @@ function mockFetch(url: string, init?: RequestInit): Response {
56
57
  return json([{ session_id: SID, name: '历史会话一', last_active: new Date(Date.now() - 3600_000).toISOString(), message_count: 5, model: 'codex', issue_title: '命令行任务' }])
57
58
  }
58
59
  if (url.endsWith('/messages') && method === 'POST') {
60
+ lastMessageBody = JSON.parse(String(init?.body || '{}'))
61
+ // /compact turns come back as claude-code local-command artifacts (command
62
+ // echo + completion stdout) instead of an assistant reply.
63
+ if (String(lastMessageBody?.content || '').trim() === '/compact') {
64
+ setTimeout(() => {
65
+ emit('typing', { active: true })
66
+ emit('jsonl_entry', { session_id: SID, entry: { type: 'user', uuid: 'flow-cmd-echo', message: { role: 'user', content: '<command-name>/compact</command-name><command-message>compact</command-message><command-args></command-args><local-command-caveat>no need to respond</local-command-caveat>' } } })
67
+ emit('jsonl_entry', { session_id: SID, entry: { type: 'user', uuid: 'flow-cmd-done', message: { role: 'user', content: [{ type: 'text', text: '<local-command-stdout>Compacted. Your new context length is 8,840 tokens</local-command-stdout>' }] } } })
68
+ emit('typing', { active: false })
69
+ }, 200)
70
+ return json({ ok: true, session_id: SID, turn_number: 2 })
71
+ }
59
72
  setTimeout(() => {
60
73
  emit('typing', { active: true })
61
74
  emit('jsonl_entry', { session_id: SID, entry: { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '已收到,这是来自 TUI 的回复。' }] } } })
@@ -205,6 +218,14 @@ async function main() {
205
218
  ok((lastFrame() ?? '').includes('?session=sess-1'), '/model new session attached')
206
219
  snap('7-after-model', lastFrame() ?? '')
207
220
 
221
+ // ── /compact: dispatch the literal command on the live session ────────
222
+ await delay(400)
223
+ stdin.write('/compact'); await delay(200)
224
+ stdin.write('\r')
225
+ ok(await waitFor(lastFrame, '上下文已压缩', 6000), '/compact renders the compact completion system line')
226
+ ok(lastMessageBody?.content === '/compact', '/compact posts the literal command to the session (web parity)')
227
+ snap('7b-after-compact', lastFrame() ?? '')
228
+
208
229
  // ── /logout ─────────────────────────────────────────────────────────────
209
230
  await delay(400)
210
231
  stdin.write('/logout'); await delay(150)
@@ -170,7 +170,7 @@ async function main() {
170
170
  stdin.write('\x1b[5~') // PageUp
171
171
  await delay(300)
172
172
  const upFrame = strip(lastFrame() ?? '')
173
- ok(upFrame.includes('↓ 较新内容'), 'after PageUp: navigation reports newer content below')
173
+ ok(upFrame.includes('↓ 有新内容'), 'after PageUp: navigation reports newer content below')
174
174
  ok(!upFrame.includes('回答 24'), 'after PageUp: latest entry paged out of view')
175
175
  ok(/回答 \d+/.test(upFrame), 'after PageUp: an older entry is visible')
176
176
 
@@ -184,7 +184,7 @@ async function main() {
184
184
  stdin.write('\x1b[<64;5;5M') // wheel up = scroll back
185
185
  await delay(300)
186
186
  const wheelUp = strip(lastFrame() ?? '')
187
- ok(wheelUp.includes('↓ 较新内容'), 'wheel up: navigation reports newer content below')
187
+ ok(wheelUp.includes('↓ 有新内容'), 'wheel up: navigation reports newer content below')
188
188
  ok(!wheelUp.includes('回答 24'), 'wheel up: latest entry paged out of view')
189
189
  ok(/回答 \d+/.test(wheelUp), 'wheel up: an older entry is visible')
190
190
 
@@ -198,7 +198,7 @@ async function main() {
198
198
  stdin.write('\x1b[M' + String.fromCharCode(96, 50, 50))
199
199
  await delay(300)
200
200
  const legacyUp = strip(lastFrame() ?? '')
201
- ok(legacyUp.includes('↓ 较新内容'), 'legacy wheel up: navigation reports newer content below')
201
+ ok(legacyUp.includes('↓ 有新内容'), 'legacy wheel up: navigation reports newer content below')
202
202
  ok(!legacyUp.includes('回答 24'), 'legacy wheel up: latest entry paged out of view')
203
203
 
204
204
  stdin.write('\x1b[M' + String.fromCharCode(97, 50, 50)) // wheel down Cb = 0x61
@@ -139,7 +139,7 @@ async function main() {
139
139
  const row1 = lines.findIndex(l => l.includes('回答 1'))
140
140
  const row3 = lines.findIndex(l => l.includes('回答 3'))
141
141
  ok(row1 >= 0 && row3 >= 0, `found 回答 1 (row ${row1}) and 回答 3 (row ${row3}) in the transcript`)
142
- ok(frame.includes('全部内容') && !frame.includes('↑ 较早内容') && !frame.includes('↓ 还有较新内容'), 'all entries fit — navigation reports the complete transcript')
142
+ ok(frame.includes('全部内容') && !frame.includes('↑ 较早内容') && !frame.includes('↓ 有新内容'), 'all entries fit — navigation reports the complete transcript')
143
143
 
144
144
  // press on 回答 1 (col 4 → first content char), drag to 回答 3 (col beyond EOL)
145
145
  stdin.write(`\x1b[<0;5;${row1 + 1}M`) // left-button press (SGR 1-based)
package/tests/ui.test.tsx CHANGED
@@ -677,6 +677,54 @@ async function testComposerMultilinePaste() {
677
677
  unmount()
678
678
  }
679
679
 
680
+ // ════════════════════════════════════════════════════════════════════════════
681
+ // TEST 9b — Idle Ctrl+C requires a second press to quit (guards accidental exit)
682
+ // ════════════════════════════════════════════════════════════════════════════
683
+ async function testComposerCtrlCConfirm() {
684
+ console.log('\n[UI 9b] idle Ctrl+C asks for a confirming second press')
685
+ let quitCalled = 0
686
+ const { stdin, lastFrame, unmount } = render(
687
+ <Composer
688
+ onSubmit={() => {}}
689
+ onStop={() => {}}
690
+ onQuit={() => { quitCalled++ }}
691
+ typing={false}
692
+ commands={[]}
693
+ />,
694
+ )
695
+ await delay(20)
696
+ stdin.write('\x03') // Ctrl+C
697
+ await delay(20)
698
+ ok(quitCalled === 0, 'first Ctrl+C does not quit')
699
+ ok((lastFrame() ?? '').includes('请再次按下Ctrl+C退出'), 'first Ctrl+C shows the confirm prompt')
700
+ stdin.write('\x03') // second Ctrl+C within the window
701
+ await delay(20)
702
+ ok(quitCalled === 1, 'second Ctrl+C quits')
703
+ unmount()
704
+
705
+ // The confirmation window expires: a Ctrl+C after 2s must arm, not quit.
706
+ let lateQuit = 0
707
+ const late = render(
708
+ <Composer
709
+ onSubmit={() => {}}
710
+ onStop={() => {}}
711
+ onQuit={() => { lateQuit++ }}
712
+ typing={false}
713
+ commands={[]}
714
+ />,
715
+ )
716
+ await delay(20)
717
+ late.stdin.write('\x03')
718
+ await delay(2100) // let the 2s window lapse
719
+ late.stdin.write('\x03')
720
+ await delay(20)
721
+ ok(lateQuit === 0, 'Ctrl+C after the window lapses re-arms instead of quitting')
722
+ late.stdin.write('\x03')
723
+ await delay(20)
724
+ ok(lateQuit === 1, 'the re-armed second Ctrl+C quits')
725
+ late.unmount()
726
+ }
727
+
680
728
  // ════════════════════════════════════════════════════════════════════════════
681
729
  // TEST 10 — Working text uses a moving multi-level brightness wave
682
730
  // ════════════════════════════════════════════════════════════════════════════
@@ -988,6 +1036,99 @@ async function testSendRetries502() {
988
1036
  } finally { restoreFetch() }
989
1037
  }
990
1038
 
1039
+ // ════════════════════════════════════════════════════════════════════════════
1040
+ // TEST 16 — /compact slash command dispatch + compact artifact rendering
1041
+ // ════════════════════════════════════════════════════════════════════════════
1042
+ async function testCompactSlash() {
1043
+ console.log('\n[UI 16] /compact slash command')
1044
+ // (a) unit: claude-code compact artifacts in the jsonl project cleanly.
1045
+ const echo = viewsForEntry({
1046
+ type: 'user',
1047
+ message: { role: 'user', content: '<command-name>/compact</command-name><command-message>compact</command-message><command-args></command-args><local-command-caveat>claude-3-5 prompted</local-command-caveat>' },
1048
+ } as any)
1049
+ ok(echo.length === 1 && echo[0].kind === 'skip', 'compact command echo (tag soup) is skipped, not shown as user text')
1050
+ const done = viewsForEntry({
1051
+ type: 'user',
1052
+ message: { role: 'user', content: [{ type: 'text', text: '<local-command-stdout>Compacted. Your new context length is 9,241 tokens</local-command-stdout>' }] },
1053
+ } as any)
1054
+ ok(done.length === 1 && done[0].kind === 'system' && (done[0] as any).text.includes('上下文已压缩'), 'compact completion stdout renders as a 上下文已压缩 system line')
1055
+ ok(done[0].kind === 'system' && (done[0] as any).text.includes('9,241 tokens'), 'compact completion keeps the token count detail')
1056
+ const goal = viewsForEntry({
1057
+ type: 'user',
1058
+ message: { role: 'user', content: '<local-command-stdout>Goal set: ship the TUI</local-command-stdout>' },
1059
+ } as any)
1060
+ ok(goal.length === 1 && goal[0].kind === 'system' && (goal[0] as any).text === 'Goal set: ship the TUI' && !(goal[0] as any).text.includes('上下文已压缩'), 'non-compact local-command stdout shows its body without the compact marker')
1061
+
1062
+ const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
1063
+ const ready: ReadyState = {
1064
+ project: { id: 'p1', name: '测试项目' },
1065
+ issue: { id: 'i1', project_id: 'p1', title: '测试任务' },
1066
+ prefs: { model: 'codex', language: 'zh', excluded_skill_ids: [], excluded_memory_ids: [] },
1067
+ }
1068
+
1069
+ // (b) no session yet → /compact refuses instead of creating an empty session.
1070
+ let posted: any = null
1071
+ installMock((url, init) => {
1072
+ if (url.includes('/events')) {
1073
+ return new Response(new RS({ start(c: any) { sseController = c; c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed","session":{}}\n\n')) } }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
1074
+ }
1075
+ if (url.endsWith('/messages') && init?.method === 'POST') { posted = JSON.parse(String(init.body || '{}')); return jsonResponse({ ok: true, session_id: 's1', turn_number: 1 }) }
1076
+ if (url.includes('/sessions') && init?.method === 'POST') return jsonResponse({ session_id: 's1' })
1077
+ return jsonResponse({ error: 'no mock' }, 404)
1078
+ })
1079
+ try {
1080
+ const { stdin, lastFrame, unmount } = render(
1081
+ <ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
1082
+ )
1083
+ await delay(40)
1084
+ stdin.write('/comp'); await delay(60)
1085
+ ok((lastFrame() ?? '').includes('/compact') && (lastFrame() ?? '').includes('压缩当前会话上下文'), '/compact appears in the slash autocomplete list')
1086
+ stdin.write('act'); await delay(40)
1087
+ stdin.write('\r'); await delay(120)
1088
+ const refused = lastFrame() ?? ''
1089
+ unmount()
1090
+ ok(refused.includes('当前没有可发送指令的会话'), '/compact without a session shows the guidance error')
1091
+ ok(posted === null, '/compact without a session dispatches nothing')
1092
+ } finally { restoreFetch() }
1093
+
1094
+ // (c) with a live session → literal '/compact' is POSTed like the web client,
1095
+ // and the streamed compact artifacts render as one system line.
1096
+ posted = null
1097
+ installMock((url, init) => {
1098
+ if (url.includes('/events')) {
1099
+ return new Response(new RS({ start(c: any) { sseController = c; c.enqueue(enc.encode('event: subscribed\ndata: {"event":"subscribed","session":{}}\n\n')) } }), { status: 200, headers: { 'content-type': 'text/event-stream' } })
1100
+ }
1101
+ if (url.endsWith('/messages') && init?.method === 'POST') {
1102
+ posted = JSON.parse(String(init.body || '{}'))
1103
+ setTimeout(() => {
1104
+ emit('typing', { active: true })
1105
+ emit('jsonl_entry', { session_id: 's1', entry: { type: 'user', uuid: 'cmd-echo', message: { role: 'user', content: '<command-name>/compact</command-name><command-message>compact</command-message><command-args></command-args><local-command-caveat>caveat</local-command-caveat>' } } })
1106
+ emit('jsonl_entry', { session_id: 's1', entry: { type: 'user', uuid: 'cmd-done', message: { role: 'user', content: [{ type: 'text', text: '<local-command-stdout>Compacted. Your new context length is 8,840 tokens</local-command-stdout>' }] } } })
1107
+ emit('typing', { active: false })
1108
+ }, 120)
1109
+ return jsonResponse({ ok: true, session_id: 's1', turn_number: 2 })
1110
+ }
1111
+ if (url.endsWith('/api/sessions/s1/status')) return jsonResponse({ session_id: 's1', alive: true, working: false })
1112
+ return jsonResponse({ error: 'no mock' }, 404)
1113
+ })
1114
+ try {
1115
+ const { stdin, lastFrame, unmount } = render(
1116
+ <ChatScreen client={client} ready={ready} webUserId="u" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
1117
+ )
1118
+ await delay(120)
1119
+ stdin.write('/compact'); await delay(40)
1120
+ stdin.write('\r')
1121
+ ok(await waitFor(lastFrame, '上下文已压缩', 4000), 'compact completion renders the 上下文已压缩 system line')
1122
+ await delay(80)
1123
+ const out = lastFrame() ?? ''
1124
+ unmount()
1125
+ ok(posted?.content === '/compact', 'the literal /compact command is POSTed to the session (web parity)')
1126
+ ok(out.includes('8,840 tokens'), 'compact system line keeps the token detail')
1127
+ ok(!out.includes('<command-name>'), 'raw local-command tags never leak into the transcript')
1128
+ ok(!out.includes('当前没有可发送指令的会话'), 'no error line once a session exists')
1129
+ } finally { restoreFetch() }
1130
+ }
1131
+
991
1132
  async function main() {
992
1133
  await testLogin()
993
1134
  await testChat()
@@ -1003,6 +1144,7 @@ async function main() {
1003
1144
  await testComposerDeleteKeys()
1004
1145
  await testCursorNavigationKeys()
1005
1146
  await testComposerMultilinePaste()
1147
+ await testComposerCtrlCConfirm()
1006
1148
  testWorkingShimmer()
1007
1149
  testReasoningViews()
1008
1150
  testCustomToolCallViews()
@@ -1011,6 +1153,7 @@ async function main() {
1011
1153
  await testChatSseReconnects()
1012
1154
  await testIdleCompletedSessionReopensSseOnSend()
1013
1155
  await testSendRetries502()
1156
+ await testCompactSlash()
1014
1157
  // cleanup temp home
1015
1158
  try { fs.rmSync(TMP_HOME, { recursive: true, force: true }) } catch { /* ignore */ }
1016
1159
  console.log(`\n==== UI RESULT: ${pass} passed, ${fail} failed ====\n`)