@mobius-os/mobius 0.3.14 → 0.3.15

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.14",
3
+ "version": "0.3.15",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
@@ -17,7 +17,7 @@ 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
+ import { isEscapeKeypress, isMouseInput, useMouseWheel } from './primitives.js'
21
21
 
22
22
  interface ChatProps {
23
23
  client: MobiusClient
@@ -134,6 +134,22 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
134
134
  else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
135
135
  })
136
136
 
137
+ // Mouse wheel: up scrolls back through history, down returns toward the
138
+ // latest, mirroring PageUp/PageDown but in small fixed steps. Handled on the
139
+ // Ink event emitter (not useInput) so the sequence can be buffered across
140
+ // read() chunks; the Composer guards against inserting mouse bytes as text.
141
+ useMouseWheel((delta) => {
142
+ if (delta === 0) return
143
+ const step = 3
144
+ setScrollBack(value => Math.min(dedupedEntries.length, Math.max(0, value + delta * step)))
145
+ })
146
+
147
+ const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
148
+ ? fitted.hiddenOlder > 0
149
+ ? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp 向上翻页`
150
+ : '已到最早记录 · 滚轮/PageDown 向下翻页'
151
+ : null
152
+
137
153
  return (
138
154
  <Box
139
155
  flexDirection="column"
@@ -147,16 +163,20 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
147
163
  ? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
148
164
  : <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
149
165
 
166
+ {/* Older-records hint is pinned OUTSIDE the flex-end scroll box so it is
167
+ always the first line of the transcript, spanning the full width,
168
+ instead of floating mid-screen when the transcript has spare rows. */}
169
+ {olderHint !== null
170
+ ? <Box width="100%" flexShrink={0}><Text dimColor> {olderHint}</Text></Box>
171
+ : null}
172
+
150
173
  <Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome ? 'flex-start' : 'flex-end'} overflowY="hidden">
151
- {fitted.hiddenOlder > 0 || scrollBack > 0
152
- ? <Text dimColor> ↑ {fitted.hiddenOlder > 0 ? `还有 ${fitted.hiddenOlder} 条较早记录 · PageUp 向上翻页` : '已到最早记录 · PageDown 向下翻页'}</Text>
153
- : null}
154
174
  {fitted.entries.map((entry, index) => (
155
175
  <EntryAccum key={entry.__id ?? `entry-${fitted.startIndex + index}`} entry={entry} columns={terminal.columns} />
156
176
  ))}
157
177
  {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
158
178
  {fitted.hiddenRecent > 0
159
- ? <Text dimColor> ↓ PageDown 向下翻页 · 较新 {fitted.hiddenRecent} 条</Text>
179
+ ? <Box width="100%" flexShrink={0}><Text dimColor> ↓ 滚轮/PageDown 向下翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
160
180
  : null}
161
181
  </Box>
162
182
 
@@ -599,6 +619,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
599
619
  useEffect(() => () => resetPasteBurst(), [])
600
620
 
601
621
  useInput((input, key) => {
622
+ if (isMouseInput(input)) return // mouse events must never become typed text
602
623
  const now = Date.now()
603
624
  const escape = isEscapeKeypress(input, key)
604
625
  if (typing && escape) { void onStop(); return }
@@ -3,13 +3,96 @@
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 } from 'ink'
6
+ import { Box, Text, useInput, useStdout, useStdin } from 'ink'
7
7
 
8
8
  /** Windows Terminal/ConPTY may expose Esc as a named key, a raw byte, or Ctrl+[. */
9
9
  export function isEscapeKeypress(input: string, key: { escape?: boolean; ctrl?: boolean }): boolean {
10
10
  return key.escape === true || input === '\x1b' || (key.ctrl === true && input === '[')
11
11
  }
12
12
 
13
+ // ─── Mouse wheel ─────────────────────────────────────────────────────────────
14
+ // Terminals report wheel events only after DECSET 1000 (button-event) + 1006
15
+ // (SGR coordinates) are enabled. A wheel tick arrives as a mouse sequence:
16
+ // wheel up → ESC [ < 64 ; x ; y M (SGR, the modern encoding)
17
+ // wheel down → ESC [ < 65 ; x ; y M
18
+ // Legacy X10 (no SGR support) reports ESC [ M Cb Cx Cy with Cb = button + 32,
19
+ // so wheel up is 0x60 (`) and wheel down is 0x61 (a). There is no release event
20
+ // for the wheel in either form. Button 64/65 map to a delta of +1/-1 so the
21
+ // transcript pager can scroll back/forward by a fixed step.
22
+ const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g
23
+ const LEGACY_MOUSE_RE = /\x1b\[M([\s\S]{3})/g
24
+
25
+ /**
26
+ * True when `input` (a chunk Ink forwarded to useInput handlers) begins with a
27
+ * mouse event. Ink strips a leading ESC before passing `input`, so both the raw
28
+ * and stripped forms are accepted. Guards must be added to any handler that
29
+ * would otherwise treat a mouse event as typed text.
30
+ */
31
+ export function isMouseInput(input: string): boolean {
32
+ return /^\x1b?\[<\d+;\d+;\d+[Mm]/.test(input) || /^\x1b?\[M/.test(input)
33
+ }
34
+
35
+ /** Extract the wheel delta from a chunk: +1 wheel-up, -1 wheel-down, else 0. */
36
+ export function mouseWheelDelta(input: string): number {
37
+ SGR_MOUSE_RE.lastIndex = 0
38
+ let delta = 0
39
+ let m: RegExpExecArray | null
40
+ while ((m = SGR_MOUSE_RE.exec(input)) !== null) {
41
+ const btn = Number(m[1])
42
+ if (btn === 64) delta++
43
+ else if (btn === 65) delta--
44
+ }
45
+ LEGACY_MOUSE_RE.lastIndex = 0
46
+ let lm: RegExpExecArray | null
47
+ while ((lm = LEGACY_MOUSE_RE.exec(input)) !== null) {
48
+ const btn = lm[1].charCodeAt(0) - 32 // X10 adds a 32 offset to the button
49
+ if (btn === 64) delta++
50
+ else if (btn === 65) delta--
51
+ }
52
+ return delta
53
+ }
54
+
55
+ /**
56
+ * Enables terminal mouse tracking for the lifetime of the calling component and
57
+ * forwards wheel deltas to `onWheel`. Mouse events reach the rest of Ink as raw
58
+ * input chunks, so any text-inserting useInput handler must guard with
59
+ * `isMouseInput(input)`.
60
+ *
61
+ * The DECSET enable/disable sequences are only written when stdout is a TTY
62
+ * (writing them into a pipe would litter the output). The emitter listener is
63
+ * attached unconditionally so the harness can simulate wheel events.
64
+ */
65
+ export function useMouseWheel(onWheel: (delta: number) => void): void {
66
+ const { internal_eventEmitter } = useStdin()
67
+ const { stdout } = useStdout()
68
+ const cbRef = useRef(onWheel)
69
+ cbRef.current = onWheel
70
+
71
+ useEffect(() => {
72
+ if (!internal_eventEmitter) return
73
+ const isTTY = Boolean(stdout.isTTY)
74
+ if (isTTY) stdout.write('\x1b[?1000h\x1b[?1006h')
75
+ let buf = ''
76
+ const handler = (chunk: unknown) => {
77
+ // A single read() chunk may carry several wheel ticks (fast scrolling) and
78
+ // an SGR sequence may be split across chunks, so accumulate and re-scan.
79
+ buf += String(chunk)
80
+ const delta = mouseWheelDelta(buf)
81
+ // Drop the fully-matched sequences, keeping any trailing partial escape
82
+ // prefix so a split sequence still matches on the next chunk.
83
+ buf = buf.replace(SGR_MOUSE_RE, '').replace(LEGACY_MOUSE_RE, '')
84
+ const esc = buf.lastIndexOf('\x1b')
85
+ buf = esc >= 0 ? buf.slice(esc) : ''
86
+ if (delta !== 0) cbRef.current(delta)
87
+ }
88
+ internal_eventEmitter.on('input', handler)
89
+ return () => {
90
+ internal_eventEmitter.off('input', handler)
91
+ if (isTTY) stdout.write('\x1b[?1000l\x1b[?1006l')
92
+ }
93
+ }, [internal_eventEmitter, stdout])
94
+ }
95
+
13
96
  // ─── TextInput ───────────────────────────────────────────────────────────────
14
97
  export interface TextInputProps {
15
98
  value: string
@@ -48,6 +131,7 @@ export function TextInput(props: TextInputProps) {
48
131
  }
49
132
 
50
133
  useInput((input, key) => {
134
+ if (isMouseInput(input)) return
51
135
  if (key.return) { props.onSubmit?.(); return }
52
136
  if (key.upArrow) { props.onArrowUp?.(); return }
53
137
  if (key.downArrow) { props.onArrowDown?.(); return }
@@ -172,6 +256,7 @@ export function Select(props: SelectProps) {
172
256
 
173
257
  useInput((input, key) => {
174
258
  if (!items.length) return
259
+ if (isMouseInput(input)) return
175
260
  if (key.upArrow) { setActive(a => (a - 1 + items.length) % items.length); return }
176
261
  if (key.downArrow) { setActive(a => (a + 1) % items.length); return }
177
262
  if (mode === 'single') {