@mobius-os/mobius 0.3.15 → 0.3.20
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 +3 -2
- package/src/App.tsx +28 -0
- package/src/components/Chat.tsx +202 -119
- package/src/components/ConfigFlow.tsx +120 -0
- package/src/components/primitives.tsx +99 -44
- package/src/lib/delete-keys.ts +152 -0
- package/src/lib/screen-text.ts +339 -0
|
@@ -4,24 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import React, { useEffect, useRef, useState } from 'react'
|
|
6
6
|
import { Box, Text, useInput, useStdout, useStdin } from 'ink'
|
|
7
|
+
import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
|
|
7
8
|
|
|
8
9
|
/** Windows Terminal/ConPTY may expose Esc as a named key, a raw byte, or Ctrl+[. */
|
|
9
10
|
export function isEscapeKeypress(input: string, key: { escape?: boolean; ctrl?: boolean }): boolean {
|
|
10
11
|
return key.escape === true || input === '\x1b' || (key.ctrl === true && input === '[')
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
// ─── Mouse
|
|
14
|
-
// Terminals report
|
|
15
|
-
// (
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
14
|
+
// ─── Mouse events ────────────────────────────────────────────────────────────
|
|
15
|
+
// Terminals report mouse events only after DECSET 1000 (button-event) + 1002
|
|
16
|
+
// (cell motion while a button is held) + 1006 (SGR coordinates) are enabled.
|
|
17
|
+
// An event arrives as a sequence:
|
|
18
|
+
// press → ESC [ < b ; x ; y M b = 0/1/2 (left/middle/right)
|
|
19
|
+
// release → ESC [ < b ; x ; y m b = 0/1/2
|
|
20
|
+
// motion → ESC [ < b ; x ; y M b = 32/33/34 (drag with button 0/1/2)
|
|
21
|
+
// wheel → ESC [ < 64 ; x ; y M (up) / < 65 (down), no release event
|
|
22
|
+
// Legacy X10 (no SGR support) reports ESC [ M Cb Cx Cy with Cb = button + 32
|
|
23
|
+
// (0x20 left, 0x23 release, 0x40 left-drag, 0x60 wheel-up, 0x61 wheel-down).
|
|
24
|
+
// Coordinates are 1-based in SGR and offset by 32 in X10; both are normalized
|
|
25
|
+
// to 0-based row/col here.
|
|
22
26
|
const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g
|
|
23
27
|
const LEGACY_MOUSE_RE = /\x1b\[M([\s\S]{3})/g
|
|
24
28
|
|
|
29
|
+
export type MouseEventInfo =
|
|
30
|
+
| { kind: 'wheel'; delta: number }
|
|
31
|
+
| { kind: 'press' | 'release' | 'motion'; button: number; row: number; col: number }
|
|
32
|
+
|
|
25
33
|
/**
|
|
26
34
|
* True when `input` (a chunk Ink forwarded to useInput handlers) begins with a
|
|
27
35
|
* mouse event. Ink strips a leading ESC before passing `input`, so both the raw
|
|
@@ -34,61 +42,94 @@ export function isMouseInput(input: string): boolean {
|
|
|
34
42
|
|
|
35
43
|
/** Extract the wheel delta from a chunk: +1 wheel-up, -1 wheel-down, else 0. */
|
|
36
44
|
export function mouseWheelDelta(input: string): number {
|
|
37
|
-
SGR_MOUSE_RE.lastIndex = 0
|
|
38
45
|
let delta = 0
|
|
46
|
+
for (const e of parseMouseEvents(input)) if (e.kind === 'wheel') delta += e.delta
|
|
47
|
+
return delta
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parse every mouse event in a chunk (may contain several; fast scroll batches). */
|
|
51
|
+
export function parseMouseEvents(input: string): MouseEventInfo[] {
|
|
52
|
+
const out: MouseEventInfo[] = []
|
|
53
|
+
SGR_MOUSE_RE.lastIndex = 0
|
|
39
54
|
let m: RegExpExecArray | null
|
|
40
55
|
while ((m = SGR_MOUSE_RE.exec(input)) !== null) {
|
|
41
56
|
const btn = Number(m[1])
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
const row = Number(m[3]) - 1
|
|
58
|
+
const col = Number(m[2]) - 1
|
|
59
|
+
const down = m[4] === 'M'
|
|
60
|
+
if (btn === 64) out.push({ kind: 'wheel', delta: 1 })
|
|
61
|
+
else if (btn === 65) out.push({ kind: 'wheel', delta: -1 })
|
|
62
|
+
else if (btn >= 32 && btn <= 34) out.push({ kind: 'motion', button: btn - 32, row, col })
|
|
63
|
+
else if (btn <= 2) out.push({ kind: down ? 'press' : 'release', button: btn, row, col })
|
|
44
64
|
}
|
|
45
65
|
LEGACY_MOUSE_RE.lastIndex = 0
|
|
46
66
|
let lm: RegExpExecArray | null
|
|
47
67
|
while ((lm = LEGACY_MOUSE_RE.exec(input)) !== null) {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
68
|
+
const bytes = lm[1]
|
|
69
|
+
const btn = bytes.charCodeAt(0) - 32
|
|
70
|
+
const row = bytes.charCodeAt(2) - 32 - 1
|
|
71
|
+
const col = bytes.charCodeAt(1) - 32 - 1
|
|
72
|
+
if (btn === 64) out.push({ kind: 'wheel', delta: 1 })
|
|
73
|
+
else if (btn === 65) out.push({ kind: 'wheel', delta: -1 })
|
|
74
|
+
else if (btn >= 32 && btn <= 34) out.push({ kind: 'motion', button: btn - 32, row, col })
|
|
75
|
+
else if (btn === 3) out.push({ kind: 'release', button: 0, row, col })
|
|
76
|
+
else if (btn <= 2) out.push({ kind: 'press', button: btn, row, col })
|
|
51
77
|
}
|
|
52
|
-
return
|
|
78
|
+
return out
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
/**
|
|
56
82
|
* Enables terminal mouse tracking for the lifetime of the calling component and
|
|
57
|
-
* forwards
|
|
58
|
-
*
|
|
59
|
-
* `isMouseInput(input)`.
|
|
83
|
+
* forwards mouse events (wheel + left-button press/motion/release) to the given
|
|
84
|
+
* handlers. Mouse events reach the rest of Ink as raw input chunks, so any
|
|
85
|
+
* text-inserting useInput handler must guard with `isMouseInput(input)`.
|
|
60
86
|
*
|
|
61
87
|
* The DECSET enable/disable sequences are only written when stdout is a TTY
|
|
62
88
|
* (writing them into a pipe would litter the output). The emitter listener is
|
|
63
|
-
* attached unconditionally so the harness can simulate
|
|
89
|
+
* attached unconditionally so the harness can simulate mouse events.
|
|
90
|
+
*
|
|
91
|
+
* Trade-off: terminal mouse reporting (DECSET 1000) hands the mouse to the app,
|
|
92
|
+
* so native drag-to-select is disabled while it is on. The app therefore draws
|
|
93
|
+
* its own selection (tmux-style) and copies via OSC 52. Users who prefer native
|
|
94
|
+
* selection can opt out with `MOBIUS_TUI_DISABLE_MOUSE=1`.
|
|
64
95
|
*/
|
|
65
|
-
export function
|
|
96
|
+
export function useMouseEvents(handlers: {
|
|
97
|
+
onWheel?: (delta: number) => void
|
|
98
|
+
onPress?: (row: number, col: number) => void
|
|
99
|
+
onMotion?: (row: number, col: number) => void
|
|
100
|
+
onRelease?: (row: number, col: number) => void
|
|
101
|
+
}): void {
|
|
66
102
|
const { internal_eventEmitter } = useStdin()
|
|
67
103
|
const { stdout } = useStdout()
|
|
68
|
-
const
|
|
69
|
-
|
|
104
|
+
const refs = useRef(handlers)
|
|
105
|
+
refs.current = handlers
|
|
70
106
|
|
|
71
107
|
useEffect(() => {
|
|
72
108
|
if (!internal_eventEmitter) return
|
|
109
|
+
if (process.env.MOBIUS_TUI_DISABLE_MOUSE === '1') return
|
|
73
110
|
const isTTY = Boolean(stdout.isTTY)
|
|
74
|
-
if (isTTY) stdout.write('\x1b[?1000h\x1b[?1006h')
|
|
111
|
+
if (isTTY) stdout.write('\x1b[?1000h\x1b[?1002h\x1b[?1006h')
|
|
75
112
|
let buf = ''
|
|
76
113
|
const handler = (chunk: unknown) => {
|
|
77
|
-
// A single read() chunk may carry several
|
|
78
|
-
//
|
|
114
|
+
// A single read() chunk may carry several events and a sequence may be
|
|
115
|
+
// split across chunks, so accumulate and re-scan.
|
|
79
116
|
buf += String(chunk)
|
|
80
|
-
const
|
|
117
|
+
for (const e of parseMouseEvents(buf)) {
|
|
118
|
+
if (e.kind === 'wheel') refs.current.onWheel?.(e.delta)
|
|
119
|
+
else if (e.kind === 'press') refs.current.onPress?.(e.row, e.col)
|
|
120
|
+
else if (e.kind === 'motion') refs.current.onMotion?.(e.row, e.col)
|
|
121
|
+
else refs.current.onRelease?.(e.row, e.col)
|
|
122
|
+
}
|
|
81
123
|
// Drop the fully-matched sequences, keeping any trailing partial escape
|
|
82
124
|
// prefix so a split sequence still matches on the next chunk.
|
|
83
125
|
buf = buf.replace(SGR_MOUSE_RE, '').replace(LEGACY_MOUSE_RE, '')
|
|
84
126
|
const esc = buf.lastIndexOf('\x1b')
|
|
85
127
|
buf = esc >= 0 ? buf.slice(esc) : ''
|
|
86
|
-
if (delta !== 0) cbRef.current(delta)
|
|
87
128
|
}
|
|
88
129
|
internal_eventEmitter.on('input', handler)
|
|
89
130
|
return () => {
|
|
90
131
|
internal_eventEmitter.off('input', handler)
|
|
91
|
-
if (isTTY) stdout.write('\x1b[?1000l\x1b[?1006l')
|
|
132
|
+
if (isTTY) stdout.write('\x1b[?1000l\x1b[?1002l\x1b[?1006l')
|
|
92
133
|
}
|
|
93
134
|
}, [internal_eventEmitter, stdout])
|
|
94
135
|
}
|
|
@@ -130,6 +171,20 @@ export function TextInput(props: TextInputProps) {
|
|
|
130
171
|
setCursor(nextCursor)
|
|
131
172
|
}
|
|
132
173
|
|
|
174
|
+
// Physical Backspace/Delete keys are owned by useDeleteKeyCapture from the
|
|
175
|
+
// raw stdin bytes — Ink reports the Backspace key (\x7f) and the Delete key
|
|
176
|
+
// (ESC[3~) both as `key.delete`, so handling `key.delete` in useInput would
|
|
177
|
+
// delete in the wrong direction. Refs keep the hook's callback on the latest
|
|
178
|
+
// value/cursor without re-subscribing.
|
|
179
|
+
const valueRef = useRef(value)
|
|
180
|
+
const cursorRef = useRef(cursor)
|
|
181
|
+
valueRef.current = value
|
|
182
|
+
cursorRef.current = cursor
|
|
183
|
+
useDeleteKeyCapture(focused, (intent) => {
|
|
184
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
|
|
185
|
+
edit(text, nextCursor)
|
|
186
|
+
})
|
|
187
|
+
|
|
133
188
|
useInput((input, key) => {
|
|
134
189
|
if (isMouseInput(input)) return
|
|
135
190
|
if (key.return) { props.onSubmit?.(); return }
|
|
@@ -137,21 +192,21 @@ export function TextInput(props: TextInputProps) {
|
|
|
137
192
|
if (key.downArrow) { props.onArrowDown?.(); return }
|
|
138
193
|
if (isEscapeKeypress(input, key)) { props.onEscape?.(); return }
|
|
139
194
|
if (key.tab) { props.onTab?.(); return }
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
195
|
+
// Only the unambiguous logical editing bindings stay here; the physical
|
|
196
|
+
// delete keys are handled above via useDeleteKeyCapture.
|
|
197
|
+
if (key.ctrl && input === 'w') {
|
|
198
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(value, cursor, 'backward-word')
|
|
199
|
+
edit(text, nextCursor)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (key.ctrl && input === 'h') {
|
|
203
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(value, cursor, 'backward')
|
|
204
|
+
edit(text, nextCursor)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if (key.ctrl && input === 'd') {
|
|
208
|
+
const { text, cursor: nextCursor } = applyDeleteIntent(value, cursor, 'forward')
|
|
209
|
+
edit(text, nextCursor)
|
|
155
210
|
return
|
|
156
211
|
}
|
|
157
212
|
if (key.leftArrow) { setCursor(c => Math.max(0, c - 1)); return }
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete / Backspace key handling.
|
|
3
|
+
*
|
|
4
|
+
* Ink's parse-keypress collapses the terminal Backspace key (0x7f) and the
|
|
5
|
+
* forward Delete key (ESC[3~) into one `key.delete` keypress with an empty
|
|
6
|
+
* `input` (its own source TODO admits the collision), so a useInput handler
|
|
7
|
+
* alone cannot tell them apart. The raw stdin chunk still can, so the input
|
|
8
|
+
* components subscribe to Ink's internal event emitter — the same channel
|
|
9
|
+
* `useMouseEvents` uses — and own the physical delete keys there, keeping only
|
|
10
|
+
* the unambiguous logical bindings (Ctrl+W / Ctrl+H / Ctrl+D) in useInput.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useRef } from 'react'
|
|
14
|
+
import { useStdin } from 'ink'
|
|
15
|
+
|
|
16
|
+
export type DeleteIntent = 'backward' | 'forward' | 'backward-word' | 'forward-word'
|
|
17
|
+
|
|
18
|
+
// Raw sequences Ink receives for physical delete keys, longest first so a
|
|
19
|
+
// longer match wins over its prefix. 0x7f is what virtually every terminal's
|
|
20
|
+
// Backspace key emits; the ESC-prefixed forms are the standard CSI sequences.
|
|
21
|
+
const DELETE_SEQUENCES: ReadonlyArray<readonly [string, DeleteIntent]> = [
|
|
22
|
+
['\x1b[3;3~', 'forward-word'], // Alt+Delete (xterm-family CSI modifier 3 = Alt)
|
|
23
|
+
['\x1b[3;5~', 'backward-word'], // Ctrl+Backspace on Windows Terminal/ConPTY; Ctrl+Delete on xterm
|
|
24
|
+
['\x1b\x7f', 'backward-word'], // Alt+Backspace (ESC + DEL)
|
|
25
|
+
['\x1b\x08', 'backward-word'], // Alt+Backspace (ESC + legacy 0x08)
|
|
26
|
+
['\x1b[3~', 'forward'], // Delete key
|
|
27
|
+
['\x7f', 'backward'], // Backspace key
|
|
28
|
+
['\x08', 'backward'], // Backspace key (legacy encoding)
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
/** Map the leading bytes of `raw` to a delete intent, or null when not a delete key. */
|
|
32
|
+
export function classifyDeleteSequence(raw: string): { intent: DeleteIntent; length: number } | null {
|
|
33
|
+
for (const [seq, intent] of DELETE_SEQUENCES) {
|
|
34
|
+
if (raw.startsWith(seq)) return { intent, length: seq.length }
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Surrogate-aware cursor helpers (also used by the composer) ─────────────
|
|
40
|
+
|
|
41
|
+
export function clampCursor(text: string, cursor: number): number {
|
|
42
|
+
let at = Math.max(0, Math.min(text.length, cursor))
|
|
43
|
+
while (at > 0 && at < text.length && /[\uDC00-\uDFFF]/.test(text[at])) at--
|
|
44
|
+
return at
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function previousCursorBoundary(text: string, cursor: number): number {
|
|
48
|
+
const at = clampCursor(text, cursor)
|
|
49
|
+
if (at <= 0) return 0
|
|
50
|
+
const code = text.charCodeAt(at - 1)
|
|
51
|
+
return at - (code >= 0xDC00 && code <= 0xDFFF ? 2 : 1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function nextCursorBoundary(text: string, cursor: number): number {
|
|
55
|
+
const at = clampCursor(text, cursor)
|
|
56
|
+
if (at >= text.length) return text.length
|
|
57
|
+
const code = text.charCodeAt(at)
|
|
58
|
+
return at + (code >= 0xD800 && code <= 0xDBFF ? 2 : 1)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Backward-word boundary: skip trailing whitespace, then the word before it. */
|
|
62
|
+
function backwardWordBoundary(text: string, at: number): number {
|
|
63
|
+
let i = at
|
|
64
|
+
while (i > 0 && /\s/.test(text[i - 1])) i--
|
|
65
|
+
while (i > 0 && !/\s/.test(text[i - 1])) i--
|
|
66
|
+
return i
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Forward-word boundary: skip leading whitespace, then the word after it. */
|
|
70
|
+
function forwardWordBoundary(text: string, at: number): number {
|
|
71
|
+
let i = at
|
|
72
|
+
while (i < text.length && /\s/.test(text[i])) i++
|
|
73
|
+
while (i < text.length && !/\s/.test(text[i])) i++
|
|
74
|
+
return i
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Apply a delete intent to `text` at `cursor`, returning the new text/cursor. */
|
|
78
|
+
export function applyDeleteIntent(
|
|
79
|
+
text: string,
|
|
80
|
+
cursor: number,
|
|
81
|
+
intent: DeleteIntent,
|
|
82
|
+
): { text: string; cursor: number } {
|
|
83
|
+
const at = clampCursor(text, cursor)
|
|
84
|
+
switch (intent) {
|
|
85
|
+
case 'backward': {
|
|
86
|
+
if (at <= 0) return { text, cursor: at }
|
|
87
|
+
const prev = previousCursorBoundary(text, at)
|
|
88
|
+
return { text: text.slice(0, prev) + text.slice(at), cursor: prev }
|
|
89
|
+
}
|
|
90
|
+
case 'forward': {
|
|
91
|
+
if (at >= text.length) return { text, cursor: at }
|
|
92
|
+
const next = nextCursorBoundary(text, at)
|
|
93
|
+
return { text: text.slice(0, at) + text.slice(next), cursor: at }
|
|
94
|
+
}
|
|
95
|
+
case 'backward-word': {
|
|
96
|
+
const start = backwardWordBoundary(text, at)
|
|
97
|
+
if (start === at) return { text, cursor: at }
|
|
98
|
+
return { text: text.slice(0, start) + text.slice(at), cursor: start }
|
|
99
|
+
}
|
|
100
|
+
case 'forward-word': {
|
|
101
|
+
const end = forwardWordBoundary(text, at)
|
|
102
|
+
if (end === at) return { text, cursor: at }
|
|
103
|
+
return { text: text.slice(0, at) + text.slice(end), cursor: at }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Owns the physical Backspace/Delete keys for one input component. Because Ink
|
|
110
|
+
* reports the Backspace key (0x7f) and the Delete key (ESC[3~) as the same
|
|
111
|
+
* `key.delete`, a useInput handler must not treat a plain `key.delete` as a
|
|
112
|
+
* backward delete — this hook resolves the intent from the raw stdin bytes and
|
|
113
|
+
* calls `onDelete` instead.
|
|
114
|
+
*
|
|
115
|
+
* `enabled` mirrors the field's focus: when false the hook stays silent so
|
|
116
|
+
* Backspace/Del pass through to other handlers (e.g. a parent list that uses
|
|
117
|
+
* Backspace to go back).
|
|
118
|
+
*/
|
|
119
|
+
export function useDeleteKeyCapture(
|
|
120
|
+
enabled: boolean,
|
|
121
|
+
onDelete: (intent: DeleteIntent) => void,
|
|
122
|
+
): void {
|
|
123
|
+
const { internal_eventEmitter } = useStdin()
|
|
124
|
+
const enabledRef = useRef(enabled)
|
|
125
|
+
const onDeleteRef = useRef(onDelete)
|
|
126
|
+
enabledRef.current = enabled
|
|
127
|
+
onDeleteRef.current = onDelete
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!internal_eventEmitter) return
|
|
131
|
+
let buf = ''
|
|
132
|
+
const handler = (chunk: unknown) => {
|
|
133
|
+
buf += String(chunk)
|
|
134
|
+
const m = classifyDeleteSequence(buf)
|
|
135
|
+
if (m) {
|
|
136
|
+
buf = buf.slice(m.length)
|
|
137
|
+
if (enabledRef.current) onDeleteRef.current(m.intent)
|
|
138
|
+
}
|
|
139
|
+
// Keep buf only when it is a strict prefix of a delete sequence (a CSI
|
|
140
|
+
// sequence split across reads, e.g. ESC then [3~), so a delete key still
|
|
141
|
+
// matches on the next chunk while ordinary text / paste content is
|
|
142
|
+
// released instead of accumulating ahead of the next keypress.
|
|
143
|
+
let keep = ''
|
|
144
|
+
for (const [seq] of DELETE_SEQUENCES) {
|
|
145
|
+
if (seq.startsWith(buf)) { keep = buf; break }
|
|
146
|
+
}
|
|
147
|
+
buf = keep
|
|
148
|
+
}
|
|
149
|
+
internal_eventEmitter.on('input', handler)
|
|
150
|
+
return () => { internal_eventEmitter.off('input', handler) }
|
|
151
|
+
}, [internal_eventEmitter])
|
|
152
|
+
}
|