@mobius-os/mobius 0.3.26 → 0.3.27

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.26",
3
+ "version": "0.3.27",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
package/src/App.tsx CHANGED
@@ -21,10 +21,16 @@ import { ResumePicker } from './components/ResumePicker.js'
21
21
  import { Screen } from './components/Screen.js'
22
22
  import { startAimuxConnection, stopAimuxConnection, type AimuxStatus } from './aimux.js'
23
23
  import { AimuxStatusLine } from './components/AimuxStatus.js'
24
+ import { useStableInput } from './components/primitives.js'
24
25
 
25
26
  type Route = 'boot' | 'login' | 'prep' | 'chat' | 'resume'
26
27
 
27
28
  export function App() {
29
+ // Keep stdin raw mode alive across route transitions. Without a persistent
30
+ // owner, Ink can briefly drop raw mode between an async picker unmount and
31
+ // the next Chat/Select mount, making the first arrows or typed characters
32
+ // appear ignored until a later key causes another render.
33
+ useStableInput(() => {})
28
34
  const [route, setRoute] = useState<Route>('boot')
29
35
  const [bootMsg, setBootMsg] = useState('初始化…')
30
36
  const [client, setClient] = useState<MobiusClient | null>(null)
@@ -8,7 +8,7 @@
8
8
  * activity, composer, and a persistent context status line.
9
9
  */
10
10
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
11
- import { Box, Text, useInput, useStdout } from 'ink'
11
+ import { Box, Text, 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'
@@ -24,7 +24,7 @@ import type { AnyEntry } from '../types.js'
24
24
  import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
25
25
  import type { AimuxStatus } from '../aimux.js'
26
26
  import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
27
- import { isEscapeKeypress, isMouseInput, useMouseEvents } from './primitives.js'
27
+ import { isEscapeKeypress, isMouseInput, useMouseEvents, useStableInput } from './primitives.js'
28
28
  import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary } from '../lib/delete-keys.js'
29
29
 
30
30
  interface ChatProps {
@@ -112,6 +112,16 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
112
112
  // (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
113
113
  // 避免在累积视图里把同一条提问显示多次.
114
114
  const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
115
+ // Cache: entry index → rendered line count. Cleared when entries or columns change.
116
+ const entryLines = useRef<Map<number, number>>(new Map())
117
+ useEffect(() => { entryLines.current.clear() }, [dedupedEntries, terminal.columns])
118
+ const getEntryLines = useCallback((i: number) => {
119
+ const c = entryLines.current.get(i)
120
+ if (c !== undefined) return c
121
+ const n = entryScreenLines(viewsForEntry(dedupedEntries[i]), terminal.columns).length || 1
122
+ entryLines.current.set(i, n)
123
+ return n
124
+ }, [dedupedEntries, terminal.columns])
115
125
  const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
116
126
  const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
117
127
  const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
@@ -149,7 +159,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
149
159
  }, [client, ready.prefs.model])
150
160
  const modelDisplay = modelLabel ?? ready.prefs.model ?? 'default'
151
161
 
152
- useInput((_input, key) => {
162
+ useStableInput((_input, key) => {
153
163
  // While a config/reconfig flow is open, this ChatScreen-level handler owns
154
164
  // Esc so cancel is reliable even mid-list-loading (a per-component
155
165
  // EscToCancel could be unmounted by the loading→loaded transition and drop
@@ -214,8 +224,24 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
214
224
  useMouseEvents({
215
225
  onWheel: (delta) => {
216
226
  if (delta === 0) return
217
- const step = 3
218
- setScrollBack(value => Math.min(dedupedEntries.length, Math.max(0, value + delta * step)))
227
+ const targetLines = 2
228
+ let lines = 0
229
+ let next = scrollBack
230
+ const n = dedupedEntries.length
231
+ if (delta > 0) {
232
+ // scroll up (older): hide more entries from the tail
233
+ for (let i = n - 1 - next; i >= 0 && lines < targetLines; i--) {
234
+ lines += getEntryLines(i)
235
+ next++
236
+ }
237
+ } else {
238
+ // scroll down (newer): unhide entries from the tail
239
+ for (let i = n - next; i < n && lines < targetLines; i++) {
240
+ lines += getEntryLines(i)
241
+ next--
242
+ }
243
+ }
244
+ setScrollBack(Math.min(n, Math.max(0, next)))
219
245
  },
220
246
  onPress: (row, col) => {
221
247
  const p = screenToSelPoint(row, col, transcriptModel, geometry)
@@ -772,7 +798,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
772
798
 
773
799
  useEffect(() => () => resetPasteBurst(), [])
774
800
 
775
- useInput((input, key) => {
801
+ useStableInput((input, key) => {
776
802
  if (isMouseInput(input)) return // mouse events must never become typed text
777
803
  const now = Date.now()
778
804
  const escape = isEscapeKeypress(input, key)
@@ -3,9 +3,20 @@
3
3
  * Select (single-choice list + multi-choice with checkboxes), and a Spinner.
4
4
  */
5
5
  import React, { useEffect, useRef, useState } from 'react'
6
- import { Box, Text, useInput, useStdout, useStdin } from 'ink'
6
+ import { Box, Text, useInput, useStdout, useStdin, type Key } from 'ink'
7
7
  import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
8
8
 
9
+ type InputHandler = (input: string, key: Key) => void
10
+
11
+ /** Keep one Ink listener while a component rerenders; read the latest handler through a ref. */
12
+ export function useStableInput(handler: InputHandler, options?: { isActive?: boolean }): void {
13
+ const handlerRef = useRef(handler)
14
+ handlerRef.current = handler
15
+ const stableRef = useRef<InputHandler | null>(null)
16
+ if (!stableRef.current) stableRef.current = (input, key) => handlerRef.current(input, key)
17
+ useInput(stableRef.current, options)
18
+ }
19
+
9
20
  /** Windows Terminal/ConPTY may expose Esc as a named key, a raw byte, or Ctrl+[. */
10
21
  export function isEscapeKeypress(input: string, key: { escape?: boolean; ctrl?: boolean }): boolean {
11
22
  return key.escape === true || input === '\x1b' || (key.ctrl === true && input === '[')
@@ -185,7 +196,7 @@ export function TextInput(props: TextInputProps) {
185
196
  edit(text, nextCursor)
186
197
  })
187
198
 
188
- useInput((input, key) => {
199
+ useStableInput((input, key) => {
189
200
  if (isMouseInput(input)) return
190
201
  if (key.return) { props.onSubmit?.(); return }
191
202
  if (key.upArrow) { props.onArrowUp?.(); return }
@@ -309,7 +320,7 @@ export function Select(props: SelectProps) {
309
320
 
310
321
  useEffect(() => { setActive(a => Math.min(a, Math.max(0, items.length - 1))) }, [items.length])
311
322
 
312
- useInput((input, key) => {
323
+ useStableInput((input, key) => {
313
324
  if (!items.length) return
314
325
  if (isMouseInput(input)) return
315
326
  if (key.upArrow) { setActive(a => (a - 1 + items.length) % items.length); return }