@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 +1 -1
- package/src/components/Chat.tsx +26 -5
- package/src/components/primitives.tsx +86 -1
package/package.json
CHANGED
package/src/components/Chat.tsx
CHANGED
|
@@ -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') {
|