@mobius-os/mobius 0.3.14 → 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.
@@ -3,13 +3,137 @@
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
+ 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
 
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.
26
+ const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g
27
+ const LEGACY_MOUSE_RE = /\x1b\[M([\s\S]{3})/g
28
+
29
+ export type MouseEventInfo =
30
+ | { kind: 'wheel'; delta: number }
31
+ | { kind: 'press' | 'release' | 'motion'; button: number; row: number; col: number }
32
+
33
+ /**
34
+ * True when `input` (a chunk Ink forwarded to useInput handlers) begins with a
35
+ * mouse event. Ink strips a leading ESC before passing `input`, so both the raw
36
+ * and stripped forms are accepted. Guards must be added to any handler that
37
+ * would otherwise treat a mouse event as typed text.
38
+ */
39
+ export function isMouseInput(input: string): boolean {
40
+ return /^\x1b?\[<\d+;\d+;\d+[Mm]/.test(input) || /^\x1b?\[M/.test(input)
41
+ }
42
+
43
+ /** Extract the wheel delta from a chunk: +1 wheel-up, -1 wheel-down, else 0. */
44
+ export function mouseWheelDelta(input: string): number {
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
54
+ let m: RegExpExecArray | null
55
+ while ((m = SGR_MOUSE_RE.exec(input)) !== null) {
56
+ const btn = Number(m[1])
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 })
64
+ }
65
+ LEGACY_MOUSE_RE.lastIndex = 0
66
+ let lm: RegExpExecArray | null
67
+ while ((lm = LEGACY_MOUSE_RE.exec(input)) !== null) {
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 })
77
+ }
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * Enables terminal mouse tracking for the lifetime of the calling component and
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)`.
86
+ *
87
+ * The DECSET enable/disable sequences are only written when stdout is a TTY
88
+ * (writing them into a pipe would litter the output). The emitter listener is
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`.
95
+ */
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 {
102
+ const { internal_eventEmitter } = useStdin()
103
+ const { stdout } = useStdout()
104
+ const refs = useRef(handlers)
105
+ refs.current = handlers
106
+
107
+ useEffect(() => {
108
+ if (!internal_eventEmitter) return
109
+ if (process.env.MOBIUS_TUI_DISABLE_MOUSE === '1') return
110
+ const isTTY = Boolean(stdout.isTTY)
111
+ if (isTTY) stdout.write('\x1b[?1000h\x1b[?1002h\x1b[?1006h')
112
+ let buf = ''
113
+ const handler = (chunk: unknown) => {
114
+ // A single read() chunk may carry several events and a sequence may be
115
+ // split across chunks, so accumulate and re-scan.
116
+ buf += String(chunk)
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
+ }
123
+ // Drop the fully-matched sequences, keeping any trailing partial escape
124
+ // prefix so a split sequence still matches on the next chunk.
125
+ buf = buf.replace(SGR_MOUSE_RE, '').replace(LEGACY_MOUSE_RE, '')
126
+ const esc = buf.lastIndexOf('\x1b')
127
+ buf = esc >= 0 ? buf.slice(esc) : ''
128
+ }
129
+ internal_eventEmitter.on('input', handler)
130
+ return () => {
131
+ internal_eventEmitter.off('input', handler)
132
+ if (isTTY) stdout.write('\x1b[?1000l\x1b[?1002l\x1b[?1006l')
133
+ }
134
+ }, [internal_eventEmitter, stdout])
135
+ }
136
+
13
137
  // ─── TextInput ───────────────────────────────────────────────────────────────
14
138
  export interface TextInputProps {
15
139
  value: string
@@ -47,27 +171,42 @@ export function TextInput(props: TextInputProps) {
47
171
  setCursor(nextCursor)
48
172
  }
49
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
+
50
188
  useInput((input, key) => {
189
+ if (isMouseInput(input)) return
51
190
  if (key.return) { props.onSubmit?.(); return }
52
191
  if (key.upArrow) { props.onArrowUp?.(); return }
53
192
  if (key.downArrow) { props.onArrowDown?.(); return }
54
193
  if (isEscapeKeypress(input, key)) { props.onEscape?.(); return }
55
194
  if (key.tab) { props.onTab?.(); return }
56
- // Ink labels the \x7f that virtually every terminal's Backspace key emits
57
- // as `key.delete` (see its parse-keypress.js TODO). Treat either signal as
58
- // a backward delete — otherwise Backspace at the end of the input is a no-op.
59
- if (key.backspace || key.delete || (key.ctrl && input === 'h')) {
60
- if (cursor > 0) {
61
- // delete word on Ctrl+W
62
- if (key.ctrl && input === 'w') {
63
- const before = value.slice(0, cursor)
64
- const m = before.match(/\S+\s*$/)
65
- const cut = m ? m[0].length : 0
66
- edit(value.slice(0, cursor - cut) + value.slice(cursor), cursor - cut)
67
- } else {
68
- edit(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1)
69
- }
70
- }
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)
71
210
  return
72
211
  }
73
212
  if (key.leftArrow) { setCursor(c => Math.max(0, c - 1)); return }
@@ -172,6 +311,7 @@ export function Select(props: SelectProps) {
172
311
 
173
312
  useInput((input, key) => {
174
313
  if (!items.length) return
314
+ if (isMouseInput(input)) return
175
315
  if (key.upArrow) { setActive(a => (a - 1 + items.length) % items.length); return }
176
316
  if (key.downArrow) { setActive(a => (a + 1) % items.length); return }
177
317
  if (mode === 'single') {
@@ -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
+ }