@mobius-os/mobius 0.3.4 → 0.3.6
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 +26 -6
- package/src/aimux.ts +23 -2
- package/src/components/Chat.tsx +19 -7
- package/src/components/PrepScreen.tsx +1 -1
- package/src/components/primitives.tsx +7 -2
- package/src/lib/entry-view.ts +1 -1
- package/src/main.tsx +2 -2
package/package.json
CHANGED
|
@@ -1,30 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mobius-os/mobius",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
|
|
6
6
|
"bin": {
|
|
7
7
|
"mobius": "bin/mobius-tui.js",
|
|
8
8
|
"mobius-tui": "bin/mobius-tui.js"
|
|
9
9
|
},
|
|
10
|
-
"scripts": {
|
|
11
|
-
"start": "tsx src/main.tsx"
|
|
12
|
-
},
|
|
13
10
|
"files": [
|
|
14
11
|
"bin",
|
|
15
12
|
"src",
|
|
16
13
|
"README.md"
|
|
17
14
|
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "tsx src/main.tsx",
|
|
17
|
+
"start": "tsx src/main.tsx",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test:integration": "tsx tests/integration.test.ts",
|
|
20
|
+
"test:ui": "tsx tests/ui.test.tsx",
|
|
21
|
+
"test:flow": "tsx tests/flow.test.tsx",
|
|
22
|
+
"test:resume": "tsx tests/resume.test.tsx",
|
|
23
|
+
"test:aimux": "tsx tests/aimux.test.tsx",
|
|
24
|
+
"test:reconnect": "tsx tests/reconnect.test.tsx",
|
|
25
|
+
"test:screen": "tsx tests/screen.test.tsx",
|
|
26
|
+
"test:scroll": "tsx tests/scroll.test.tsx",
|
|
27
|
+
"test": "npm run typecheck && npm run test:ui && npm run test:integration"
|
|
28
|
+
},
|
|
18
29
|
"dependencies": {
|
|
19
30
|
"chalk": "^5.3.0",
|
|
20
31
|
"cli-highlight": "2.1.11",
|
|
21
32
|
"extract-zip": "^2.0.1",
|
|
22
33
|
"ink": "5.2.0",
|
|
23
34
|
"marked": "12.0.2",
|
|
24
|
-
"react": "18.3.1"
|
|
25
|
-
|
|
35
|
+
"react": "18.3.1"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "18.19.34",
|
|
39
|
+
"@types/react": "18.3.3",
|
|
40
|
+
"ink-testing-library": "4.0.0",
|
|
41
|
+
"tsx": "4.19.2",
|
|
42
|
+
"typescript": "5.4.5"
|
|
26
43
|
},
|
|
27
44
|
"engines": {
|
|
28
45
|
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
29
49
|
}
|
|
30
50
|
}
|
package/src/aimux.ts
CHANGED
|
@@ -209,6 +209,27 @@ export function tuiAimuxIdentifier(): string {
|
|
|
209
209
|
return `tui-${host || 'pc'}`
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Build the reverse-connect command in one place. The TUI can launch AIMUX
|
|
214
|
+
* through either a venv executable or bundled Python; both paths must request
|
|
215
|
+
* hidden Windows shells or every remote command flashes a console and steals
|
|
216
|
+
* keyboard focus from the TUI.
|
|
217
|
+
*/
|
|
218
|
+
export function reverseConnectArgs(
|
|
219
|
+
server: string,
|
|
220
|
+
identifier: string,
|
|
221
|
+
token: string,
|
|
222
|
+
platform: NodeJS.Platform = process.platform,
|
|
223
|
+
): string[] {
|
|
224
|
+
return [
|
|
225
|
+
'reverse', 'connect', `${server.replace(/\/$/, '')}/aimux_bridge`,
|
|
226
|
+
'--identifier', identifier,
|
|
227
|
+
'--token', token,
|
|
228
|
+
'--replace',
|
|
229
|
+
...(platform === 'win32' ? ['--silent-shell'] : []),
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
|
|
212
233
|
export async function probeAimuxBridgeConnection(
|
|
213
234
|
server: string,
|
|
214
235
|
token: string,
|
|
@@ -261,7 +282,7 @@ export class AimuxSupervisor {
|
|
|
261
282
|
onStatus({ state: 'starting', phase: 'connecting', detail: '正在连接 Mobius AIMUX bridge…', identifier, attempt: this.reconnectAttempt })
|
|
262
283
|
const child = this.opts.spawnProcess?.() ?? spawn(
|
|
263
284
|
aimuxExe(),
|
|
264
|
-
|
|
285
|
+
reverseConnectArgs(server, identifier, token),
|
|
265
286
|
{ windowsHide: true },
|
|
266
287
|
)
|
|
267
288
|
this.child = child
|
|
@@ -402,7 +423,7 @@ export async function startAimuxConnection(opts: { server: string; token: string
|
|
|
402
423
|
const launcher = ready.launcher
|
|
403
424
|
supervisor = new AimuxSupervisor({
|
|
404
425
|
server: opts.server, token: opts.token, identifier, onStatus,
|
|
405
|
-
spawnProcess: () => spawnLauncher(launcher,
|
|
426
|
+
spawnProcess: () => spawnLauncher(launcher, reverseConnectArgs(opts.server, identifier, opts.token)),
|
|
406
427
|
})
|
|
407
428
|
supervisor.start()
|
|
408
429
|
})().finally(() => { installing = null })
|
package/src/components/Chat.tsx
CHANGED
|
@@ -12,11 +12,12 @@ import { Box, Static, Text, useInput, useStdout } from 'ink'
|
|
|
12
12
|
import { useChat } from '../hooks/useChat.js'
|
|
13
13
|
import { MobiusClient } from '../api.js'
|
|
14
14
|
import { renderMarkdownLines } from '../markdown.js'
|
|
15
|
-
import { viewsForEntry, dedupeUserEntries, toolLabel, type EntryView } from '../lib/entry-view.js'
|
|
15
|
+
import { viewsForEntry, dedupeUserEntries, toolLabel, isAssistantOutput, type EntryView } from '../lib/entry-view.js'
|
|
16
16
|
import type { ReadyState } from './PrepScreen.js'
|
|
17
17
|
import type { AnyEntry } from '../types.js'
|
|
18
18
|
import type { AimuxStatus } from '../aimux.js'
|
|
19
19
|
import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
|
|
20
|
+
import { isEscapeKeypress } from './primitives.js'
|
|
20
21
|
|
|
21
22
|
interface ChatProps {
|
|
22
23
|
client: MobiusClient
|
|
@@ -79,6 +80,14 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
79
80
|
// (the terminal's own scrollback holds history; no in-app pager needed).
|
|
80
81
|
const showWelcome = chat.entries.length === 0 && chat.pendingUser === null
|
|
81
82
|
|
|
83
|
+
// First query of a fresh session triggers the full backend bootstrap (lazy
|
|
84
|
+
// session creation, worker spawn, context load) before any output streams.
|
|
85
|
+
// Label that phase "Initializing for the first query" instead of "Working"
|
|
86
|
+
// so it reads as startup rather than a stuck agent. Once the first assistant
|
|
87
|
+
// output is observed (or the session is a resumed one with prior history),
|
|
88
|
+
// the indicator falls back to the normal Working label for every turn.
|
|
89
|
+
const firstQueryInFlight = !resumeSessionId && !chat.entries.some(isAssistantOutput)
|
|
90
|
+
|
|
82
91
|
// 用户输入去重 (对齐 web viewer/rounds.ts buildRounds): codex 同一提问的 3 形态
|
|
83
92
|
// (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
|
|
84
93
|
// 避免在累积视图里把同一条提问显示多次.
|
|
@@ -112,7 +121,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
112
121
|
</Static>
|
|
113
122
|
|
|
114
123
|
{chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
|
|
115
|
-
{chat.typing ? <WorkingIndicator /> : null}
|
|
124
|
+
{chat.typing ? <WorkingIndicator firstQuery={firstQueryInFlight} /> : null}
|
|
116
125
|
{chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
|
|
117
126
|
|
|
118
127
|
{chat.entries.length === 0 && chat.pendingUser === null && !showHelp
|
|
@@ -346,7 +355,7 @@ function UserLine({ text }: { text: string }) {
|
|
|
346
355
|
return <Box marginTop={1}><Text bold>{lines.join('\n')}</Text></Box>
|
|
347
356
|
}
|
|
348
357
|
|
|
349
|
-
function WorkingIndicator() {
|
|
358
|
+
function WorkingIndicator({ firstQuery }: { firstQuery: boolean }) {
|
|
350
359
|
const startedAt = useRef(Date.now())
|
|
351
360
|
const [animationFrame, setAnimationFrame] = useState(0)
|
|
352
361
|
useEffect(() => {
|
|
@@ -355,7 +364,9 @@ function WorkingIndicator() {
|
|
|
355
364
|
}, [])
|
|
356
365
|
const secs = Math.floor((Date.now() - startedAt.current) / 1000)
|
|
357
366
|
const elapsed = secs >= 60 ? `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, '0')}s` : `${secs}s`
|
|
358
|
-
const label =
|
|
367
|
+
const label = firstQuery
|
|
368
|
+
? `• Initializing for the first query (${elapsed})`
|
|
369
|
+
: `• Working (${elapsed} · esc to interrupt)`
|
|
359
370
|
return (
|
|
360
371
|
<Box marginTop={1}>
|
|
361
372
|
<Text>{shimmerText(label, animationFrame)}</Text>
|
|
@@ -423,7 +434,8 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
|
|
|
423
434
|
}
|
|
424
435
|
|
|
425
436
|
useInput((input, key) => {
|
|
426
|
-
|
|
437
|
+
const escape = isEscapeKeypress(input, key)
|
|
438
|
+
if (typing && escape) { void onStop(); return }
|
|
427
439
|
|
|
428
440
|
if (popupOpen) {
|
|
429
441
|
if (key.upArrow) { setPopupIdx(i => (i <= 0 ? filtered.length - 1 : i - 1)); return }
|
|
@@ -432,7 +444,7 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
|
|
|
432
444
|
const pick = filtered[popupIdx >= 0 ? popupIdx : 0]
|
|
433
445
|
if (pick) { edit(`${pick.cmd} `, pick.cmd.length + 1); setPopupDismissed(true); return }
|
|
434
446
|
}
|
|
435
|
-
if (
|
|
447
|
+
if (escape) { setPopupDismissed(true); return }
|
|
436
448
|
}
|
|
437
449
|
|
|
438
450
|
if (key.return) {
|
|
@@ -476,7 +488,7 @@ function Composer({ onSubmit, onStop, onQuit, typing, commands }: ComposerProps)
|
|
|
476
488
|
if (key.ctrl && input === 'u') { edit('', 0); return }
|
|
477
489
|
if (key.ctrl && input === 'k') { edit(value.slice(0, cursor), cursor); return }
|
|
478
490
|
if (key.ctrl && input === 'j') { edit(value.slice(0, cursor) + '\n' + value.slice(cursor), cursor + 1); return }
|
|
479
|
-
if (key.ctrl || key.meta ||
|
|
491
|
+
if (key.ctrl || key.meta || escape || !input) return
|
|
480
492
|
edit(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length)
|
|
481
493
|
})
|
|
482
494
|
|
|
@@ -293,7 +293,7 @@ function IssuePicker({ issues, onPick, onCreate }: {
|
|
|
293
293
|
<Text bold color="cyan">创建新任务</Text>
|
|
294
294
|
<Text color="gray">输入任务名称(不使用 git worktree)</Text>
|
|
295
295
|
<TextInput value={name} onChange={setName} focused placeholder="命令行任务"
|
|
296
|
-
onSubmit={() => onCreate(name)} />
|
|
296
|
+
onSubmit={() => onCreate(name)} onEscape={() => setMode('list')} />
|
|
297
297
|
<Text color="gray">回车创建 · Esc 返回</Text>
|
|
298
298
|
</Box>
|
|
299
299
|
)
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
import React, { useEffect, useRef, useState } from 'react'
|
|
6
6
|
import { Box, Text, useInput, useStdout } from 'ink'
|
|
7
7
|
|
|
8
|
+
/** Windows Terminal/ConPTY may expose Esc as a named key, a raw byte, or Ctrl+[. */
|
|
9
|
+
export function isEscapeKeypress(input: string, key: { escape?: boolean; ctrl?: boolean }): boolean {
|
|
10
|
+
return key.escape === true || input === '\x1b' || (key.ctrl === true && input === '[')
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
// ─── TextInput ───────────────────────────────────────────────────────────────
|
|
9
14
|
export interface TextInputProps {
|
|
10
15
|
value: string
|
|
@@ -46,7 +51,7 @@ export function TextInput(props: TextInputProps) {
|
|
|
46
51
|
if (key.return) { props.onSubmit?.(); return }
|
|
47
52
|
if (key.upArrow) { props.onArrowUp?.(); return }
|
|
48
53
|
if (key.downArrow) { props.onArrowDown?.(); return }
|
|
49
|
-
if (
|
|
54
|
+
if (isEscapeKeypress(input, key)) { props.onEscape?.(); return }
|
|
50
55
|
if (key.tab) { props.onTab?.(); return }
|
|
51
56
|
// Ink labels the \x7f that virtually every terminal's Backspace key emits
|
|
52
57
|
// as `key.delete` (see its parse-keypress.js TODO). Treat either signal as
|
|
@@ -175,7 +180,7 @@ export function Select(props: SelectProps) {
|
|
|
175
180
|
if (key.return) { props.onConfirm?.(Array.from(selectedSet)); return }
|
|
176
181
|
if (input === ' ') { props.onToggle?.(items[active].value); return }
|
|
177
182
|
}
|
|
178
|
-
if (key
|
|
183
|
+
if (isEscapeKeypress(input, key)) { props.onBack?.(); return }
|
|
179
184
|
}, { isActive: props.focused !== false })
|
|
180
185
|
|
|
181
186
|
// viewport: keep the active item on screen. Without this a long list renders
|
package/src/lib/entry-view.ts
CHANGED
|
@@ -669,7 +669,7 @@ export function stripUserFraming(text: string): string {
|
|
|
669
669
|
return after || text
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
-
function isAssistantOutput(e: AnyEntry): boolean {
|
|
672
|
+
export function isAssistantOutput(e: AnyEntry): boolean {
|
|
673
673
|
if (e?.type === 'assistant') return true
|
|
674
674
|
if (e?.type === 'event_msg' && e?.payload?.type === 'agent_message') return true
|
|
675
675
|
if (e?.type === 'response_item') {
|
package/src/main.tsx
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Run: npx tsx src/main.tsx (or `npm start`)
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Ink owns Ctrl+C globally so Windows users can always exit, including from
|
|
7
|
+
* setup screens that do not mount the chat composer.
|
|
8
8
|
*/
|
|
9
9
|
import React from 'react'
|
|
10
10
|
import { render } from 'ink'
|