@mobius-os/mobius 0.3.42 → 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 +5 -1
- package/src/components/Chat.tsx +34 -3
- 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 -241
- package/tests/flow.test.tsx +0 -253
- 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 -1163
- 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')
|
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
|
|
@@ -62,6 +63,7 @@ const SLASH_COMMANDS = [
|
|
|
62
63
|
{ cmd: '/config', desc: '重新选择项目、任务和模型' },
|
|
63
64
|
{ cmd: '/logout', desc: '断开当前连接并返回登录界面' },
|
|
64
65
|
{ cmd: '/help', desc: '显示帮助' },
|
|
66
|
+
{ cmd: '/version', desc: '显示 TUI、AIMUX 和运行环境版本' },
|
|
65
67
|
{ cmd: '/quit', desc: '退出 TUI' },
|
|
66
68
|
]
|
|
67
69
|
|
|
@@ -77,6 +79,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
77
79
|
const [reconfigOpen, setReconfigOpen] = useState(false)
|
|
78
80
|
// 本地命令错误 (如无会话时 /compact): 与 chat.error 分开, 下一次提交时清除。
|
|
79
81
|
const [slashError, setSlashError] = useState<string | null>(null)
|
|
82
|
+
const [versionInfo, setVersionInfo] = useState<string[] | null>(null)
|
|
80
83
|
// Ink may deliver one final event to Composer while an async config picker is
|
|
81
84
|
// replacing it. The shared ref lets that stale listener report "not handled"
|
|
82
85
|
// so App can replay the key after the new Select mounts.
|
|
@@ -88,6 +91,9 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
88
91
|
const handlerRef = useRef<{ configOpen: boolean; reconfigOpen: boolean; sessionId: string | null }>({ configOpen: false, reconfigOpen: false, sessionId: null })
|
|
89
92
|
handlerRef.current = { configOpen, reconfigOpen, sessionId: chat.sessionId }
|
|
90
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()
|
|
91
97
|
|
|
92
98
|
const runSlash = useCallback((raw: string) => {
|
|
93
99
|
const [name] = raw.trim().split(/\s+/)
|
|
@@ -108,13 +114,27 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
108
114
|
}
|
|
109
115
|
case '/resume': onResume(); return true
|
|
110
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
|
+
}
|
|
111
131
|
case '/model': setConfigOpen(true); return true
|
|
112
132
|
case '/config': setReconfigOpen(true); return true
|
|
113
133
|
case '/logout': onLogout(); return true
|
|
114
134
|
case '/quit': case '/exit': onQuit(); return true
|
|
115
135
|
default: return false
|
|
116
136
|
}
|
|
117
|
-
}, [chat, onClear, onResume, onQuit, onLogout])
|
|
137
|
+
}, [aimuxStatus, chat, client.server, modelLabel, ready.prefs.model, onClear, onResume, onQuit, onLogout])
|
|
118
138
|
|
|
119
139
|
const onSubmit = useCallback((text: string) => {
|
|
120
140
|
const t = text.trim()
|
|
@@ -125,6 +145,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
125
145
|
return
|
|
126
146
|
}
|
|
127
147
|
setShowHelp(false)
|
|
148
|
+
setVersionInfo(null)
|
|
128
149
|
setRowAnchor(null)
|
|
129
150
|
void chat.send(t)
|
|
130
151
|
}, [chat, runSlash])
|
|
@@ -177,7 +198,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
177
198
|
(entry) => rowsForEntry(entry),
|
|
178
199
|
), [transcriptEntries, rowsForEntry])
|
|
179
200
|
const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
|
|
180
|
-
const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0) + (slashError ? 1 : 0)
|
|
201
|
+
const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0) + (slashError ? 1 : 0) + (versionInfo ? versionInfo.length + 2 : 0)
|
|
181
202
|
const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
|
|
182
203
|
// Conversation chrome is exactly two rows: compact header + navigation.
|
|
183
204
|
const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 2)
|
|
@@ -384,6 +405,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
384
405
|
{chat.typing ? <WorkingIndicator firstQuery={firstQueryInFlight} /> : null}
|
|
385
406
|
{chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
|
|
386
407
|
{slashError ? <Text color="red">⚠ {slashError}</Text> : null}
|
|
408
|
+
{versionInfo ? <VersionBlock lines={versionInfo} /> : null}
|
|
387
409
|
|
|
388
410
|
<Composer
|
|
389
411
|
onSubmit={onSubmit}
|
|
@@ -576,6 +598,15 @@ function HelpBlock({ commands }: { commands: { cmd: string; desc: string }[] })
|
|
|
576
598
|
)
|
|
577
599
|
}
|
|
578
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
|
+
|
|
579
610
|
interface ComposerProps {
|
|
580
611
|
onSubmit: (text: string) => void
|
|
581
612
|
onStop: () => void
|
|
@@ -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
|
+
}
|
package/install.ps1
DELETED
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
#requires -Version 5.1
|
|
2
|
-
<#
|
|
3
|
-
.SYNOPSIS
|
|
4
|
-
Mobius TUI Windows 便携安装 (自带 portable Node, 无需 admin, 不依赖 npm.ps1)
|
|
5
|
-
.DESCRIPTION
|
|
6
|
-
下载 portable Node win-x64 zip 解压到 ~/.mobius/node-portable/ → 用 node.exe 直跑 npm-cli.js
|
|
7
|
-
装 @mobius-os/mobius 到 ~/.mobius/npm-global/ → 自建 mobius.cmd 启动器(绝对路径指向便携 node)
|
|
8
|
-
→ 加 ~/.mobius/bin 到用户 PATH (无需 admin)。
|
|
9
|
-
npm 安装和 mobius 启动均直接使用 node.exe/tsx;安装完成后同时注册两个
|
|
10
|
-
Explorer 右键入口:“在 Mobius 中打开”(文件夹本身 + 文件夹空白处)。
|
|
11
|
-
.EXAMPLE
|
|
12
|
-
irm https://serve.nutshellai.cn/publish/auto/mobiustui/install-v15.ps1 | iex
|
|
13
|
-
#>
|
|
14
|
-
$ErrorActionPreference = 'Stop'
|
|
15
|
-
$ProgressPreference = 'SilentlyContinue'
|
|
16
|
-
|
|
17
|
-
function Step($m){ Write-Host "[*] $m" -ForegroundColor Cyan }
|
|
18
|
-
function Ok($m) { Write-Host "[OK] $m" -ForegroundColor Green }
|
|
19
|
-
function Warn($m){ Write-Host "[!] $m" -ForegroundColor Yellow }
|
|
20
|
-
function Err($m) { Write-Host "[X] $m" -ForegroundColor Red }
|
|
21
|
-
function Fail($m) { throw $m }
|
|
22
|
-
|
|
23
|
-
function Invoke-MobiusInstall {
|
|
24
|
-
Write-Host "=== Mobius TUI Windows 便携安装 (无需 admin) ===" -ForegroundColor White
|
|
25
|
-
|
|
26
|
-
$MHOME = Join-Path $env:USERPROFILE ".mobius"
|
|
27
|
-
$NODE_DIR = Join-Path $MHOME "node-portable"
|
|
28
|
-
$GLOBAL_DIR = Join-Path $MHOME "npm-global"
|
|
29
|
-
$NODE_VER = "v24.18.1"
|
|
30
|
-
$ZIP_URL = "https://serve.nutshellai.cn/publish/auto/mobius-tui/node/node-$NODE_VER-win-x64.zip"
|
|
31
|
-
$NODE_SHA256 = "ec56b84a7551893ab2324ebdfdc4ab974a63b4781162600b68a1293cc3e53765" # node v24.18.1 win-x64
|
|
32
|
-
$nodeExe = Join-Path $NODE_DIR "node.exe"
|
|
33
|
-
$npmCli = Join-Path $NODE_DIR "node_modules\npm\bin\npm-cli.js"
|
|
34
|
-
|
|
35
|
-
New-Item -ItemType Directory -Force -Path $MHOME | Out-Null
|
|
36
|
-
|
|
37
|
-
# --- 1. portable Node ---
|
|
38
|
-
if (Test-Path $nodeExe) {
|
|
39
|
-
Ok "便携 Node 已存在: $NODE_DIR"
|
|
40
|
-
} else {
|
|
41
|
-
Step "下载便携 Node $NODE_VER (~37MB), 可能需 1-2 分钟..."
|
|
42
|
-
$zip = Join-Path $MHOME "node.zip"
|
|
43
|
-
Invoke-WebRequest -Uri $ZIP_URL -OutFile $zip
|
|
44
|
-
Step "校验 sha256..."
|
|
45
|
-
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
|
|
46
|
-
if ($actual -ne $NODE_SHA256.ToLower()) { Fail "Node zip sha256 不匹配 (下载损坏? 期望 $NODE_SHA256 实际 $actual)" }
|
|
47
|
-
Step "解压..."
|
|
48
|
-
$tmp = Join-Path $MHOME "_extract"
|
|
49
|
-
if (Test-Path $tmp) { Remove-Item $tmp -Recurse -Force }
|
|
50
|
-
Expand-Archive -Path $zip -DestinationPath $tmp -Force
|
|
51
|
-
$inner = Join-Path $tmp "node-$NODE_VER-win-x64"
|
|
52
|
-
if (-not (Test-Path (Join-Path $inner "node.exe"))) { Fail "解压后未找到 node.exe" }
|
|
53
|
-
if (Test-Path $NODE_DIR) { Remove-Item $NODE_DIR -Recurse -Force }
|
|
54
|
-
Move-Item $inner $NODE_DIR -Force
|
|
55
|
-
Remove-Item $tmp -Recurse -Force
|
|
56
|
-
Remove-Item $zip -Force
|
|
57
|
-
Ok "便携 Node 就绪: $NODE_DIR"
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
# --- 2. npm 装 @mobius-os/mobius (node.exe 直跑 npm-cli.js, 绕过 npm.ps1 的 ExecutionPolicy 拦截) ---
|
|
61
|
-
Step "npm 安装 @mobius-os/mobius@latest (本地 install 到便携目录, 官方 registry)..."
|
|
62
|
-
New-Item -ItemType Directory -Force -Path $GLOBAL_DIR | Out-Null
|
|
63
|
-
$oldNodeModules = Join-Path $GLOBAL_DIR "node_modules"
|
|
64
|
-
$oldPackageJson = Join-Path $GLOBAL_DIR "package.json"
|
|
65
|
-
for ($attempt = 1; $attempt -le 3 -and (Test-Path $oldNodeModules); $attempt++) {
|
|
66
|
-
Remove-Item $oldNodeModules -Recurse -Force -ErrorAction SilentlyContinue
|
|
67
|
-
if (Test-Path $oldNodeModules) { Start-Sleep -Milliseconds (300 * $attempt) }
|
|
68
|
-
}
|
|
69
|
-
if (Test-Path $oldNodeModules) {
|
|
70
|
-
Warn "请关闭正在运行的 mobius 终端后重试: $oldNodeModules"
|
|
71
|
-
Fail "无法清理旧 node_modules,可能仍有 Mobius/Node 进程占用文件"
|
|
72
|
-
}
|
|
73
|
-
Remove-Item $oldPackageJson -Force -ErrorAction SilentlyContinue
|
|
74
|
-
$npmLog = Join-Path $MHOME "npm-install.log"
|
|
75
|
-
$npmStdout = Join-Path $MHOME "npm-install.stdout.log"
|
|
76
|
-
$npmStderr = Join-Path $MHOME "npm-install.stderr.log"
|
|
77
|
-
$previousPath = $env:Path
|
|
78
|
-
# npm lifecycle scripts use cmd.exe and resolve `node` from PATH.
|
|
79
|
-
$env:Path = "$NODE_DIR;$env:Path"
|
|
80
|
-
Push-Location $GLOBAL_DIR
|
|
81
|
-
$previousErrorActionPreference = $ErrorActionPreference
|
|
82
|
-
$ErrorActionPreference = "Continue"
|
|
83
|
-
try {
|
|
84
|
-
# PowerShell 5.1 会把 npm 的 stderr warning 包装成 NativeCommandError。
|
|
85
|
-
# 合并输出并只依据原生进程退出码判定成败,避免 warning 提前终止脚本。
|
|
86
|
-
& $nodeExe $npmCli init -y 2>&1 | Out-Null
|
|
87
|
-
$initExitCode = $LASTEXITCODE
|
|
88
|
-
if ($initExitCode -ne 0) { throw "npm 初始化失败 (退出码 $initExitCode)" }
|
|
89
|
-
|
|
90
|
-
# npm 11 默认拦截 esbuild postinstall,安装前明确允许该脚本。旧 npm 会忽略该字段。
|
|
91
|
-
& $nodeExe $npmCli pkg set "allowScripts.esbuild=true" --json 2>&1 | Out-Null
|
|
92
|
-
$configExitCode = $LASTEXITCODE
|
|
93
|
-
if ($configExitCode -ne 0) { throw "配置 esbuild 安装脚本授权失败 (退出码 $configExitCode)" }
|
|
94
|
-
|
|
95
|
-
function Invoke-NpmInstallAttempt([string]$registry, [int]$timeoutSeconds) {
|
|
96
|
-
Remove-Item $npmStdout, $npmStderr -Force -ErrorAction SilentlyContinue
|
|
97
|
-
# Do not call System.Diagnostics.Process instance methods here.
|
|
98
|
-
# Windows PowerShell ConstrainedLanguage blocks those methods even
|
|
99
|
-
# though Start-Process itself is allowed. A tiny cmd wrapper records
|
|
100
|
-
# %ERRORLEVEL%; Wait-Process and taskkill remain CLM-safe.
|
|
101
|
-
$attemptCmd = Join-Path $GLOBAL_DIR 'npm-install-attempt.cmd'
|
|
102
|
-
$exitCodeFile = Join-Path $GLOBAL_DIR 'npm-install.exitcode'
|
|
103
|
-
Remove-Item $exitCodeFile -Force -ErrorAction SilentlyContinue
|
|
104
|
-
$attemptLines = @(
|
|
105
|
-
'@echo off',
|
|
106
|
-
'setlocal',
|
|
107
|
-
('set "PATH={0};%PATH%"' -f $NODE_DIR),
|
|
108
|
-
('"{0}" "{1}" install "@mobius-os/mobius@latest" --registry "{2}" --loglevel warn >"{3}" 2>"{4}"' -f $nodeExe, $npmCli, $registry, $npmStdout, $npmStderr),
|
|
109
|
-
'set "EXIT_CODE=%ERRORLEVEL%"',
|
|
110
|
-
('>"{0}" echo %EXIT_CODE%' -f $exitCodeFile),
|
|
111
|
-
'exit /b %EXIT_CODE%'
|
|
112
|
-
)
|
|
113
|
-
Set-Content -Path $attemptCmd -Value $attemptLines -Encoding ASCII
|
|
114
|
-
$proc = Start-Process -FilePath $env:ComSpec -ArgumentList @('/d', '/s', '/c', ('"{0}"' -f $attemptCmd)) `
|
|
115
|
-
-WorkingDirectory $GLOBAL_DIR -PassThru -WindowStyle Hidden
|
|
116
|
-
Wait-Process -Id $proc.Id -Timeout $timeoutSeconds -ErrorAction SilentlyContinue | Out-Null
|
|
117
|
-
# Allow cmd a short moment to flush the marker after it exits, without
|
|
118
|
-
# invoking any restricted Process instance methods.
|
|
119
|
-
for ($markerAttempt = 1; $markerAttempt -le 20 -and -not (Test-Path $exitCodeFile); $markerAttempt++) {
|
|
120
|
-
Start-Sleep -Milliseconds 100
|
|
121
|
-
}
|
|
122
|
-
if (-not (Test-Path $exitCodeFile)) {
|
|
123
|
-
Warn "npm 官方源安装超过 $timeoutSeconds 秒,正在终止并切换镜像源..."
|
|
124
|
-
& $env:ComSpec /d /s /c "taskkill /PID $($proc.Id) /T /F" 2>&1 | Out-Null
|
|
125
|
-
Start-Sleep -Milliseconds 300
|
|
126
|
-
Remove-Item $attemptCmd -Force -ErrorAction SilentlyContinue
|
|
127
|
-
return @{ TimedOut = $true; ExitCode = $null }
|
|
128
|
-
}
|
|
129
|
-
$exitText = Get-Content -Path $exitCodeFile -Raw -ErrorAction SilentlyContinue
|
|
130
|
-
$exitCode = $exitText -as [int]
|
|
131
|
-
if ($null -eq $exitCode) { $exitCode = 1 }
|
|
132
|
-
Remove-Item $attemptCmd, $exitCodeFile -Force -ErrorAction SilentlyContinue
|
|
133
|
-
return @{ TimedOut = $false; ExitCode = $exitCode }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function Save-NpmAttemptLog([string]$label) {
|
|
137
|
-
Add-Content -Path $npmLog -Value "`n===== $label ====="
|
|
138
|
-
if (Test-Path $npmStdout) { Get-Content $npmStdout | Add-Content -Path $npmLog }
|
|
139
|
-
if (Test-Path $npmStderr) { Get-Content $npmStderr | Add-Content -Path $npmLog }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
Remove-Item $npmLog -Force -ErrorAction SilentlyContinue
|
|
143
|
-
$official = Invoke-NpmInstallAttempt "https://registry.npmjs.org/" 10
|
|
144
|
-
Save-NpmAttemptLog "official registry.npmjs.org"
|
|
145
|
-
$npmSucceeded = (-not $official.TimedOut -and $official.ExitCode -eq 0)
|
|
146
|
-
if (-not $npmSucceeded) {
|
|
147
|
-
if ($official.TimedOut) {
|
|
148
|
-
Warn "官方源超时,切换 npmmirror 镜像源重试..."
|
|
149
|
-
} else {
|
|
150
|
-
Warn "官方源安装失败 (退出码 $($official.ExitCode)),切换 npmmirror 镜像源重试..."
|
|
151
|
-
}
|
|
152
|
-
# A timed-out npm process can leave a partial tree behind; remove only
|
|
153
|
-
# the package tree and keep the user-level install directory intact.
|
|
154
|
-
if (Test-Path $oldNodeModules) { Remove-Item $oldNodeModules -Recurse -Force -ErrorAction SilentlyContinue }
|
|
155
|
-
$mirror = Invoke-NpmInstallAttempt "https://registry.npmmirror.com/" 120
|
|
156
|
-
Save-NpmAttemptLog "mirror registry.npmmirror.com"
|
|
157
|
-
$npmSucceeded = (-not $mirror.TimedOut -and $mirror.ExitCode -eq 0)
|
|
158
|
-
}
|
|
159
|
-
if (-not $npmSucceeded) {
|
|
160
|
-
Err "npm 安装失败"
|
|
161
|
-
if (Test-Path $npmLog) {
|
|
162
|
-
Write-Host "--- npm 最近日志: $npmLog ---" -ForegroundColor Yellow
|
|
163
|
-
Get-Content $npmLog -Tail 120
|
|
164
|
-
Write-Host "--- npm 日志结束 ---" -ForegroundColor Yellow
|
|
165
|
-
}
|
|
166
|
-
Fail "npm 安装失败,完整日志: $npmLog"
|
|
167
|
-
}
|
|
168
|
-
} finally {
|
|
169
|
-
$ErrorActionPreference = $previousErrorActionPreference
|
|
170
|
-
$env:Path = $previousPath
|
|
171
|
-
Pop-Location
|
|
172
|
-
}
|
|
173
|
-
$tsxCli = Join-Path $GLOBAL_DIR "node_modules\tsx\dist\cli.mjs"
|
|
174
|
-
$esbuildExe = Join-Path $GLOBAL_DIR "node_modules\@esbuild\win32-x64\esbuild.exe"
|
|
175
|
-
if (-not (Test-Path $tsxCli)) { Fail "npm 包缺少 tsx 运行时依赖,日志: $npmLog" }
|
|
176
|
-
if (-not (Test-Path $esbuildExe)) { Fail "esbuild Windows 二进制未正确安装,日志: $npmLog" }
|
|
177
|
-
Ok "mobius 装到: $GLOBAL_DIR"
|
|
178
|
-
|
|
179
|
-
# --- 3. mobius 启动器 (.cmd batch, 用绝对路径指向便携 node, 不依赖系统 PATH/ExecutionPolicy) ---
|
|
180
|
-
$binDir = Join-Path $MHOME "bin"
|
|
181
|
-
$entryJs = Join-Path $GLOBAL_DIR "node_modules\@mobius-os\mobius\bin\mobius-tui.js"
|
|
182
|
-
if (-not (Test-Path $entryJs)) { Fail "未找到 mobius 入口: $entryJs" }
|
|
183
|
-
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
|
|
184
|
-
$mainTsx = Join-Path $GLOBAL_DIR "node_modules\@mobius-os\mobius\src\main.tsx"
|
|
185
|
-
$mobiusCmd = Join-Path $binDir "mobius.cmd"
|
|
186
|
-
@"
|
|
187
|
-
@echo off
|
|
188
|
-
"$nodeExe" "$tsxCli" "$mainTsx" %*
|
|
189
|
-
"@ | Set-Content -Path $mobiusCmd -Encoding ASCII
|
|
190
|
-
Ok "启动器: $mobiusCmd"
|
|
191
|
-
|
|
192
|
-
# --- 4. 用户 PATH (无需 admin) ---
|
|
193
|
-
$userPath = (Get-ItemProperty -Path 'HKCU:\Environment' -Name Path -ErrorAction SilentlyContinue).Path
|
|
194
|
-
if ($userPath -notlike "*$binDir*") {
|
|
195
|
-
$newPath = if ($userPath) { "$userPath;$binDir" } else { $binDir }
|
|
196
|
-
if (Test-Path 'HKCU:\Environment') {
|
|
197
|
-
Set-ItemProperty -Path 'HKCU:\Environment' -Name Path -Value $newPath
|
|
198
|
-
} else {
|
|
199
|
-
New-Item -Path 'HKCU:\Environment' -Force | Out-Null
|
|
200
|
-
New-ItemProperty -Path 'HKCU:\Environment' -Name Path -Value $newPath -PropertyType ExpandString -Force | Out-Null
|
|
201
|
-
}
|
|
202
|
-
$env:Path += ";$binDir"
|
|
203
|
-
Ok "已加入用户 PATH: $binDir (重开 PowerShell 生效)"
|
|
204
|
-
} else {
|
|
205
|
-
Ok "PATH 已含: $binDir"
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
# --- 5. Explorer 右键菜单 (当前用户,无需 admin) ----------------------------
|
|
209
|
-
# 两个入口必须同时注册:
|
|
210
|
-
# Directory\shell\Mobius = 右键文件夹本身,目标参数 %1
|
|
211
|
-
# Directory\Background\shell\Mobius = 右键文件夹空白处,目标参数 %V
|
|
212
|
-
# 使用 .cmd 辅助启动器,避免右键动作依赖 PowerShell ExecutionPolicy。
|
|
213
|
-
$openHereCmd = Join-Path $binDir "mobius-open-here.cmd"
|
|
214
|
-
$openHereLines = @(
|
|
215
|
-
'@echo off',
|
|
216
|
-
'setlocal',
|
|
217
|
-
'set "MOBIUS_TARGET=%~1"',
|
|
218
|
-
'if not defined MOBIUS_TARGET set "MOBIUS_TARGET=%CD%"',
|
|
219
|
-
'cd /d "%MOBIUS_TARGET%"',
|
|
220
|
-
'call "%~dp0mobius.cmd"',
|
|
221
|
-
'endlocal'
|
|
222
|
-
)
|
|
223
|
-
Set-Content -Path $openHereCmd -Value $openHereLines -Encoding ASCII
|
|
224
|
-
|
|
225
|
-
$classes = "HKCU:\Software\Classes"
|
|
226
|
-
$folderMenu = Join-Path $classes "Directory\shell\Mobius"
|
|
227
|
-
$folderCommand = Join-Path $folderMenu "command"
|
|
228
|
-
$backgroundMenu = Join-Path $classes "Directory\Background\shell\Mobius"
|
|
229
|
-
$backgroundCommand = Join-Path $backgroundMenu "command"
|
|
230
|
-
|
|
231
|
-
foreach ($key in @($folderCommand, $backgroundCommand)) {
|
|
232
|
-
New-Item -Path $key -Force | Out-Null
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
foreach ($menu in @($folderMenu, $backgroundMenu)) {
|
|
236
|
-
Set-ItemProperty -Path $menu -Name "MUIVerb" -Value "在 Mobius 中打开"
|
|
237
|
-
Set-ItemProperty -Path $menu -Name "Icon" -Value "$env:SystemRoot\System32\cmd.exe,0"
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
# Explorer command quoting: cmd /c ""helper.cmd" "%1"".
|
|
241
|
-
$folderCommandValue = '"{0}" /d /s /c ""{1}" "%1""' -f $env:ComSpec, $openHereCmd
|
|
242
|
-
$backgroundCommandValue = '"{0}" /d /s /c ""{1}" "%V""' -f $env:ComSpec, $openHereCmd
|
|
243
|
-
Set-Item -Path $folderCommand -Value $folderCommandValue
|
|
244
|
-
Set-Item -Path $backgroundCommand -Value $backgroundCommandValue
|
|
245
|
-
Ok "右键菜单已添加: 在 Mobius 中打开 (文件夹 + 空白处)"
|
|
246
|
-
|
|
247
|
-
Write-Host ""
|
|
248
|
-
Write-Host "=== 完成! 重开 PowerShell 后运行: mobius ===" -ForegroundColor Green
|
|
249
|
-
Write-Host "(或直接运行: $mobiusCmd)" -ForegroundColor Gray
|
|
250
|
-
Write-Host "右键文件夹或文件夹空白处,可选择: 在 Mobius 中打开" -ForegroundColor Gray
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
try {
|
|
254
|
-
Invoke-MobiusInstall
|
|
255
|
-
} catch {
|
|
256
|
-
$errorLog = Join-Path $env:TEMP "mobius-install-v15-error.log"
|
|
257
|
-
$errorText = $_ | Format-List * -Force | Out-String
|
|
258
|
-
$errorText | Set-Content -Path $errorLog -Encoding UTF8
|
|
259
|
-
Write-Host ""
|
|
260
|
-
Err "Mobius 安装失败: $($_.Exception.Message)"
|
|
261
|
-
Write-Host "完整错误日志: $errorLog" -ForegroundColor Yellow
|
|
262
|
-
Write-Host $errorText -ForegroundColor DarkYellow
|
|
263
|
-
try { Read-Host "按 Enter 返回 PowerShell(窗口不会自动关闭)" | Out-Null } catch { }
|
|
264
|
-
return
|
|
265
|
-
}
|