@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 +7 -24
- package/src/aimux.ts +62 -9
- package/src/components/Chat.tsx +80 -9
- package/src/lib/entry-view.ts +40 -0
- package/src/lib/paint-flush.ts +223 -0
- package/install.ps1 +0 -265
- package/scripts/build-python-bundles.sh +0 -129
- package/tests/aimux.test.tsx +0 -221
- package/tests/flow.test.tsx +0 -232
- package/tests/integration.test.ts +0 -114
- package/tests/preview.tsx +0 -104
- package/tests/reconnect.test.tsx +0 -170
- package/tests/resume.test.tsx +0 -73
- package/tests/screen.test.tsx +0 -118
- package/tests/scroll.test.tsx +0 -253
- package/tests/selection.test.tsx +0 -219
- package/tests/ui.test.tsx +0 -1020
- package/tests/viewport.test.ts +0 -83
- package/tsconfig.json +0 -19
- package/uninstall.ps1 +0 -144
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mobius-os/mobius",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
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' ? [
|
|
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 })
|
package/src/components/Chat.tsx
CHANGED
|
@@ -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
|
|
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
|
|
218
|
-
?
|
|
219
|
-
:
|
|
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"> {
|
|
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') {
|
|
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
|
-
|
|
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>
|
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
|
}
|
|
@@ -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
|
+
}
|