@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 +1 -1
- package/src/aimux.ts +57 -8
- package/src/components/Chat.tsx +48 -8
- package/src/lib/entry-view.ts +40 -0
- package/tests/aimux.test.tsx +27 -7
- package/tests/flow.test.tsx +21 -0
- package/tests/scroll.test.tsx +3 -3
- package/tests/selection.test.tsx +1 -1
- package/tests/ui.test.tsx +143 -0
package/package.json
CHANGED
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.
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
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' ? [
|
|
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 })
|
package/src/components/Chat.tsx
CHANGED
|
@@ -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
|
|
218
|
-
?
|
|
219
|
-
:
|
|
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"> {
|
|
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') {
|
|
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
|
-
|
|
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>
|
package/src/lib/entry-view.ts
CHANGED
|
@@ -87,6 +87,31 @@ function entryUserText(entry: AnyEntry): string {
|
|
|
87
87
|
|
|
88
88
|
const ENV_CONTEXT_RE = /<environment_context\b[^>]*>[\s\S]*?<\/environment_context>/gi
|
|
89
89
|
|
|
90
|
+
// ── Claude Code 本地命令产物标签 (/compact 等) ─────────────────────────────
|
|
91
|
+
// slash command 在 claude-code jsonl 里以 user 外壳 + 下列标签出现, 不是人类提问:
|
|
92
|
+
// <command-name>/compact</command-name> 等 命令回显 (噪声)
|
|
93
|
+
// <local-command-caveat>…</local-command-caveat> "由本地命令产生" 提示 (噪声)
|
|
94
|
+
// <local-command-stdout>Compacted …</local-command-stdout> 命令输出 (压缩完成信号)
|
|
95
|
+
// 对齐 web entry-extract.ts 的 extractLocalCommandParts (精简版), 渲染时不能把
|
|
96
|
+
// 标签原文当用户消息显示.
|
|
97
|
+
const LOCAL_COMMAND_TAG_PATTERN = /<(local-command-stdout|local-command-caveat|command-name|command-message|command-args)>\s*([\s\S]*?)<\/\1>/gi
|
|
98
|
+
|
|
99
|
+
interface LocalCommandPart { tag: string; body: string }
|
|
100
|
+
|
|
101
|
+
function extractLocalCommandParts(entry: AnyEntry): LocalCommandPart[] {
|
|
102
|
+
if (entry?.type !== 'user') return []
|
|
103
|
+
const text = entryUserText(entry)
|
|
104
|
+
if (!text || !text.includes('<')) return []
|
|
105
|
+
const parts: LocalCommandPart[] = []
|
|
106
|
+
LOCAL_COMMAND_TAG_PATTERN.lastIndex = 0
|
|
107
|
+
let m: RegExpExecArray | null
|
|
108
|
+
while ((m = LOCAL_COMMAND_TAG_PATTERN.exec(text))) {
|
|
109
|
+
const body = m[2].replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim()
|
|
110
|
+
parts.push({ tag: m[1].toLowerCase(), body })
|
|
111
|
+
}
|
|
112
|
+
return parts
|
|
113
|
+
}
|
|
114
|
+
|
|
90
115
|
/**
|
|
91
116
|
* 整卡隐藏的噪声: 对齐 web entry-classify.ts isHiddenJsonlNoiseEntry 的 7 类
|
|
92
117
|
* - token_count : codex 每轮 token 用量统计 (event_msg)
|
|
@@ -558,6 +583,21 @@ export function viewsForBlock(block: Block): EntryView[] {
|
|
|
558
583
|
}
|
|
559
584
|
return out
|
|
560
585
|
}
|
|
586
|
+
// Claude Code 本地命令产物 (/compact 等): 命令回显/caveat 标签是噪声 → 整条
|
|
587
|
+
// 隐藏; local-command-stdout 渲染成 system 行, "Compacted …" 对齐 codex 的
|
|
588
|
+
// context_compacted 事件显示 "◇ 上下文已压缩"。
|
|
589
|
+
const localParts = extractLocalCommandParts(entry)
|
|
590
|
+
if (localParts.length > 0) {
|
|
591
|
+
const out: EntryView[] = []
|
|
592
|
+
for (const part of localParts) {
|
|
593
|
+
if (part.tag !== 'local-command-stdout' || !part.body) continue
|
|
594
|
+
const text = /^compacted\b/i.test(part.body)
|
|
595
|
+
? `◇ 上下文已压缩 · ${part.body}`
|
|
596
|
+
: part.body
|
|
597
|
+
out.push({ kind: 'system', text: truncate(text, 160) })
|
|
598
|
+
}
|
|
599
|
+
return out.length ? out : [{ kind: 'skip' }]
|
|
600
|
+
}
|
|
561
601
|
const text = entryUserText(entry)
|
|
562
602
|
return text ? [{ kind: 'user', text: stripUserFraming(text) }] : [{ kind: 'skip' }]
|
|
563
603
|
}
|
package/tests/aimux.test.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import os from 'node:os'
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { render } from 'ink-testing-library'
|
|
9
9
|
import { AimuxStatusLine } from '../src/components/AimuxStatus.js'
|
|
10
|
-
import { AimuxSupervisor, probeAimuxBridgeConnection, bundleArch, bundleUrl, spawnLauncher, ensureFromBundle, downloadBundleForTest, reverseConnectArgs, aimuxLogPath, bundleHealthCheckCode, 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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
ok(
|
|
141
|
-
ok(
|
|
136
|
+
console.log('\n[AIMUX 6] reverse connect silent flag adapts to installed aimux')
|
|
137
|
+
// Old aimux (PyPI 0.1.20 / cached bundle 0.1.21): advertises only --silent-shell.
|
|
138
|
+
// Must NOT send the newer --slient-v2 it doesn't know — that is the crash-loop bug.
|
|
139
|
+
const oldWin = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32', '--silent-shell')
|
|
140
|
+
ok(oldWin.includes('--silent-shell'), 'old aimux gets the --silent-shell flag it supports')
|
|
141
|
+
ok(!oldWin.includes('--slient-v2') && !oldWin.includes('--silent-v2'), 'old aimux never gets the unsupported v2 flag (no crash-loop)')
|
|
142
|
+
// New aimux (0.1.22+): probe resolves the correctly-spelled --silent-v2.
|
|
143
|
+
const newWin = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32', '--silent-v2')
|
|
144
|
+
ok(newWin.includes('--silent-v2'), 'new aimux gets the no-console v2 flag')
|
|
145
|
+
// Probe found nothing supported (or pre-probe default): send nothing, stay alive.
|
|
146
|
+
const bareWin = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32', null)
|
|
147
|
+
ok(!bareWin.some(a => a === '--slient-v2' || a === '--silent-v2' || a === '--silent-shell'), 'unknown aimux gets no silent flag rather than crash-looping')
|
|
148
|
+
// Off-Windows: never any silent flag, regardless of what the probe found.
|
|
149
|
+
const linux = reverseConnectArgs('https://mobius.test/', 'tui-linux', 'jwt-test', 'linux', '--silent-v2')
|
|
150
|
+
ok(!linux.includes('--silent-v2') && !linux.includes('--silent-shell'), 'non-Windows never receives a Windows-only flag')
|
|
151
|
+
ok(oldWin[2] === 'https://mobius.test/aimux_bridge', 'reverse connection normalizes the bridge URL')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function testPickSilentFlag() {
|
|
155
|
+
console.log('\n[AIMUX 6b] pickSilentFlag reads what aimux advertises')
|
|
156
|
+
ok(pickSilentFlag(' --slient-v2, --silent-v2 Hide console.', 'win32') === '--silent-v2', 'prefers correct --silent-v2 spelling when both aliases are advertised')
|
|
157
|
+
ok(pickSilentFlag(' --slient-v2 Hide console.', 'win32') === '--slient-v2', 'falls back to the historical --slient-v2 alias')
|
|
158
|
+
ok(pickSilentFlag(' --silent-shell Hide console.', 'win32') === '--silent-shell', 'old aimux advertising only --silent-shell')
|
|
159
|
+
ok(pickSilentFlag('Usage: aimux reverse connect ...', 'win32') === null, 'unsupported aimux → null (send nothing, avoid crash-loop)')
|
|
160
|
+
ok(pickSilentFlag(' --silent-v2 Hide console.', 'linux') === null, 'off-Windows → always null')
|
|
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()
|
package/tests/flow.test.tsx
CHANGED
|
@@ -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)
|
package/tests/scroll.test.tsx
CHANGED
|
@@ -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('↓
|
|
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('↓
|
|
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('↓
|
|
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
|
package/tests/selection.test.tsx
CHANGED
|
@@ -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('↓
|
|
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`)
|