@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 CHANGED
@@ -1,30 +1,50 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.4",
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
- "tsx": "4.19.2"
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
- ['reverse', 'connect', `${server.replace(/\/$/, '')}/aimux_bridge`, '--identifier', identifier, '--token', token, '--replace', ...(process.platform === 'win32' ? ['--silent-shell'] : [])],
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, ['reverse', 'connect', `${opts.server.replace(/\/$/, '')}/aimux_bridge`, '--identifier', identifier, '--token', opts.token, '--replace']),
426
+ spawnProcess: () => spawnLauncher(launcher, reverseConnectArgs(opts.server, identifier, opts.token)),
406
427
  })
407
428
  supervisor.start()
408
429
  })().finally(() => { installing = null })
@@ -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 = `• Working (${elapsed} · esc to interrupt)`
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
- if (typing && key.escape) { void onStop(); return }
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 (key.escape) { setPopupDismissed(true); return }
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 || key.escape || !input) return
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 (key.escape || input === '\x1b') { props.onEscape?.(); return }
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.escape) { props.onBack?.(); return }
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
@@ -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
- * exitOnCtrlC is disabled the composer interprets Ctrl+C itself (stop the
7
- * current generation while busy; quit when idle). Use /quit to exit explicitly.
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'