@mobius-os/mobius 0.3.15 → 0.3.24

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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * /model flow — pick a model, then create a brand-new session in the CURRENT
3
+ * task (Issue) with that model. Launched from inside the chat; Esc at any point
4
+ * before the session is created cancels back to the conversation untouched.
5
+ *
6
+ * The active Issue is intentionally NOT changed: /model only swaps the model and
7
+ * starts a fresh session, keeping the current project/task context. Esc-cancel is
8
+ * owned by ChatScreen (its useInput handles Esc while configOpen), so this
9
+ * component only ever reports a completed pick via onDone.
10
+ *
11
+ * Preferences are stored inside the current Issue (same model as PrepScreen):
12
+ * updateIssuePreference — persist the chosen model on the active issue
13
+ * The session body mirrors useChat.ensureSession() so the pc_client_metadata
14
+ * (is_tui, aimux_id, local_path) matches lazily-created sessions exactly.
15
+ *
16
+ * ─────────────────────────────────────────────────────────────────────────────
17
+ * /config flow (ReconfigFlow) — full reconfiguration: (1) project → (2) issue →
18
+ * (3) model, then create a brand-new session. Unlike /model, this CAN change the
19
+ * active project and issue. Project/issue choices are persisted (bindCwdToProject,
20
+ * setCwdIssue, updateIssuePreference) so they become the new defaults.
21
+ */
22
+ import React, { useEffect, useRef, useState } from 'react'
23
+ import { Box, Text } from 'ink'
24
+ import { Select, TextInput, Spinner, type SelectItem } from './primitives.js'
25
+ import { MobiusClient } from '../api.js'
26
+ import {
27
+ bindCwdToProject, cwd, getCwdPreference, loadDir2Project, loadProjectsCache,
28
+ saveProjectsCache, setCwdIssue, updateIssuePreference, type IssuePreference,
29
+ } from '../config.js'
30
+ import { tuiAimuxIdentifier } from '../aimux.js'
31
+ import type { Issue, Project, SessionModelOption } from '../types.js'
32
+
33
+ export interface ConfigResult {
34
+ /** If set, the project was also changed (from /config full reflow). */
35
+ project?: Project
36
+ issue: Issue
37
+ prefs: IssuePreference
38
+ sessionId: string
39
+ }
40
+
41
+ export function ConfigFlow({ client, issue, onDone }: {
42
+ client: MobiusClient
43
+ issue: Issue
44
+ onDone: (r: ConfigResult) => void
45
+ }) {
46
+ const [step, setStep] = useState<'models' | 'creating'>('models')
47
+ const [models, setModels] = useState<SessionModelOption[] | null>(null)
48
+ const [defaultKey, setDefaultKey] = useState<string | null>(null)
49
+ const [status, setStatus] = useState('')
50
+ const doneRef = useRef(false)
51
+
52
+ // Guard against a setState after App has already remounted Chat (onDone fires
53
+ // a synchronous route change that unmounts us); also avoids double onDone.
54
+ useEffect(() => () => { doneRef.current = true }, [])
55
+
56
+ // Load the model list + default on mount (no issue step — the current Issue is used).
57
+ useEffect(() => {
58
+ Promise.all([
59
+ client.modelOptions().catch(() => [] as SessionModelOption[]),
60
+ client.defaultModel().then(r => r.model).catch(() => null),
61
+ ]).then(([opts, def]) => {
62
+ if (doneRef.current) return
63
+ setModels(opts)
64
+ setDefaultKey(def)
65
+ })
66
+ }, [client])
67
+
68
+ async function pickModel(model: string) {
69
+ setStep('creating')
70
+ try {
71
+ const prefs = await updateIssuePreference(cwd(), issue.id, { model })
72
+ if (doneRef.current) return
73
+ const s = await client.createSession(issue.id, {
74
+ name: `TUI ${new Date().toISOString().slice(5, 16).replace('T', ' ')}`,
75
+ model,
76
+ language: prefs.language,
77
+ excluded_skill_ids: prefs.excluded_skill_ids,
78
+ excluded_memory_ids: prefs.excluded_memory_ids,
79
+ pc_client_metadata: {
80
+ work_mode: 'pc',
81
+ aimux_id: tuiAimuxIdentifier(),
82
+ local_path: process.cwd(),
83
+ is_tui: true,
84
+ add_remote_aimux_mcp: true,
85
+ },
86
+ })
87
+ if (doneRef.current) return
88
+ onDone({ issue, prefs, sessionId: s.session_id })
89
+ } catch (e: any) {
90
+ if (doneRef.current) return
91
+ setStatus(`创建新会话失败: ${e?.message ?? e}`)
92
+ setStep('models')
93
+ }
94
+ }
95
+
96
+ if (step === 'creating') {
97
+ return (
98
+ <Box paddingX={2} paddingY={1}>
99
+ <Spinner label="正在创建新会话…" />
100
+ </Box>
101
+ )
102
+ }
103
+
104
+ return (
105
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
106
+ <Text bold color="cyan">更换模型</Text>
107
+ <Text color="gray">当前任务: {issue.title}</Text>
108
+ {status ? <Text color="yellow">{status}</Text> : null}
109
+
110
+ <Box flexDirection="column">
111
+ <Text bold color="cyan">选择模型</Text>
112
+ <Text color="gray">确认后创建新会话(保留当前任务)</Text>
113
+ <Box marginTop={1}>
114
+ {models === null
115
+ ? <Text color="cyan">加载模型列表…</Text>
116
+ : models.length === 0
117
+ ? <Text color="gray">(无可用模型)</Text>
118
+ : <Select
119
+ items={models.map(o => ({
120
+ label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
121
+ value: o.key,
122
+ desc: o.sub,
123
+ }))}
124
+ onSelect={key => void pickModel(key)}
125
+ />}
126
+ </Box>
127
+ <Text color="gray">↑↓ 选择 · 回车确认 · Esc 取消</Text>
128
+ </Box>
129
+ </Box>
130
+ )
131
+ }
132
+
133
+ // ── /config: full reconfigure (project → issue → model) ─────────────────────
134
+
135
+ type ReconfigStep = 'projects' | 'issues' | 'models' | 'creating'
136
+
137
+ export function ReconfigFlow({ client, onDone }: {
138
+ client: MobiusClient
139
+ onDone: (r: ConfigResult) => void
140
+ }) {
141
+ const [step, setStep] = useState<ReconfigStep>('projects')
142
+ const [projects, setProjects] = useState<Project[] | null>(null)
143
+ const [project, setProject] = useState<Project | null>(null)
144
+ const [issues, setIssues] = useState<Issue[] | null>(null)
145
+ const [issue, setIssue] = useState<Issue | null>(null)
146
+ const [models, setModels] = useState<SessionModelOption[] | null>(null)
147
+ const [defaultKey, setDefaultKey] = useState<string | null>(null)
148
+ const [status, setStatus] = useState('')
149
+ const [createName, setCreateName] = useState('')
150
+ const [createMode, setCreateMode] = useState<'project' | 'issue' | null>(null)
151
+ const doneRef = useRef(false)
152
+ const thisCwd = cwd()
153
+
154
+ useEffect(() => () => { doneRef.current = true }, [])
155
+
156
+ // Load projects on mount.
157
+ useEffect(() => {
158
+ ;(async () => {
159
+ let list = await loadProjectsCache()
160
+ try { list = await client.listProjects(); await saveProjectsCache(list) } catch { /* use cache */ }
161
+ if (doneRef.current) return
162
+ setProjects(list)
163
+ })().catch(e => { if (!doneRef.current) setStatus(`加载项目失败: ${e?.message ?? e}`) })
164
+ }, [client])
165
+
166
+ // Load issues when a project is picked.
167
+ useEffect(() => {
168
+ if (!project || step !== 'issues') return
169
+ ;(async () => {
170
+ try {
171
+ const iss = await client.listIssues(project.id, 'active')
172
+ if (doneRef.current) return
173
+ setIssues(iss)
174
+ } catch (e: any) {
175
+ if (!doneRef.current) setStatus(`加载任务失败: ${e?.message ?? e}`)
176
+ }
177
+ })()
178
+ }, [project, step, client])
179
+
180
+ // Load models when we reach the model step.
181
+ useEffect(() => {
182
+ if (step !== 'models') return
183
+ Promise.all([
184
+ client.modelOptions().catch(() => [] as SessionModelOption[]),
185
+ client.defaultModel().then(r => r.model).catch(() => null),
186
+ ]).then(([opts, def]) => {
187
+ if (doneRef.current) return
188
+ setModels(opts)
189
+ setDefaultKey(def)
190
+ })
191
+ }, [step, client])
192
+
193
+ // ── project ───────────────────────────────────────────────────────────────
194
+ async function pickProject(p: Project) {
195
+ await bindCwdToProject(thisCwd, p.id)
196
+ if (doneRef.current) return
197
+ setProject(p)
198
+ setIssues(null)
199
+ setStep('issues')
200
+ }
201
+
202
+ async function createProject(name: string) {
203
+ setCreateMode(null)
204
+ setStatus('创建项目…')
205
+ try {
206
+ const safeDir = '/' + (name || '未命名项目').replace(/[^a-zA-Z0-9一-鿿_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 64) || 'project'
207
+ const p = await client.createProject({ name: name || '未命名项目', description: '', bindPath: safeDir, defaultUseWorktree: false })
208
+ const list = await client.listProjects(); await saveProjectsCache(list)
209
+ if (doneRef.current) return
210
+ setProjects(list)
211
+ await bindCwdToProject(thisCwd, p.id)
212
+ setProject(p)
213
+ setIssues(null)
214
+ setStep('issues')
215
+ setStatus('')
216
+ } catch (e: any) { if (!doneRef.current) setStatus(`创建项目失败: ${e?.message ?? e}`) }
217
+ }
218
+
219
+ // ── issue ─────────────────────────────────────────────────────────────────
220
+ async function pickIssue(iss: Issue) {
221
+ await setCwdIssue(thisCwd, iss.id, iss.title)
222
+ if (doneRef.current) return
223
+ setIssue(iss)
224
+ setStep('models')
225
+ }
226
+
227
+ async function createIssue(name: string) {
228
+ if (!project) return
229
+ setCreateMode(null)
230
+ setStatus('创建任务…')
231
+ try {
232
+ const iss = await client.createIssue(project.id, { title: name || '命令行任务', description: '由 TUI 创建', use_worktree: false })
233
+ const refreshed = await client.listIssues(project.id, 'active')
234
+ if (doneRef.current) return
235
+ setIssues(refreshed)
236
+ await pickIssue(iss)
237
+ setStatus('')
238
+ } catch (e: any) { if (!doneRef.current) setStatus(`创建任务失败: ${e?.message ?? e}`) }
239
+ }
240
+
241
+ // ── model → session ───────────────────────────────────────────────────────
242
+ async function pickModel(model: string) {
243
+ if (!project || !issue) return
244
+ setStep('creating')
245
+ try {
246
+ const prefs = await updateIssuePreference(thisCwd, issue.id, { model })
247
+ if (doneRef.current) return
248
+ const s = await client.createSession(issue.id, {
249
+ name: `TUI ${new Date().toISOString().slice(5, 16).replace('T', ' ')}`,
250
+ model,
251
+ language: prefs.language,
252
+ excluded_skill_ids: prefs.excluded_skill_ids,
253
+ excluded_memory_ids: prefs.excluded_memory_ids,
254
+ pc_client_metadata: {
255
+ work_mode: 'pc',
256
+ aimux_id: tuiAimuxIdentifier(),
257
+ local_path: process.cwd(),
258
+ is_tui: true,
259
+ add_remote_aimux_mcp: true,
260
+ },
261
+ })
262
+ if (doneRef.current) return
263
+ onDone({ project, issue, prefs, sessionId: s.session_id })
264
+ } catch (e: any) {
265
+ if (doneRef.current) return
266
+ setStatus(`创建新会话失败: ${e?.message ?? e}`)
267
+ setStep('models')
268
+ }
269
+ }
270
+
271
+ // ── render ────────────────────────────────────────────────────────────────
272
+ if (step === 'creating') {
273
+ return (
274
+ <Box paddingX={2} paddingY={1}>
275
+ <Spinner label="正在创建新会话…" />
276
+ </Box>
277
+ )
278
+ }
279
+
280
+ if (createMode === 'project') {
281
+ return (
282
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
283
+ <Text bold color="cyan">创建新项目</Text>
284
+ <Text color="gray">{thisCwd}</Text>
285
+ <Box marginTop={1} flexDirection="column">
286
+ <Text color="cyan">项目名称 ←</Text>
287
+ <TextInput value={createName} onChange={setCreateName} focused placeholder="未命名项目"
288
+ onSubmit={() => createProject(createName)} onEscape={() => { setCreateMode(null); setCreateName('') }} />
289
+ </Box>
290
+ {status ? <Text color="yellow">{status}</Text> : null}
291
+ <Text color="gray">回车创建 · Esc 返回</Text>
292
+ </Box>
293
+ )
294
+ }
295
+
296
+ if (createMode === 'issue') {
297
+ return (
298
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
299
+ <Text bold color="cyan">创建新任务</Text>
300
+ <Text color="gray">项目: {project?.name}</Text>
301
+ <TextInput value={createName} onChange={setCreateName} focused placeholder="命令行任务"
302
+ onSubmit={() => createIssue(createName)} onEscape={() => { setCreateMode(null); setCreateName('') }} />
303
+ {status ? <Text color="yellow">{status}</Text> : null}
304
+ <Text color="gray">回车创建 · Esc 返回</Text>
305
+ </Box>
306
+ )
307
+ }
308
+
309
+ return (
310
+ <Box flexDirection="column" paddingX={2} paddingY={1}>
311
+ <Text bold color="cyan">重新配置</Text>
312
+ {status ? <Text color="yellow">{status}</Text> : null}
313
+
314
+ {step === 'projects' ? (
315
+ <Box flexDirection="column">
316
+ <Text bold color="cyan">选择项目</Text>
317
+ <Box marginTop={1}>
318
+ {projects === null
319
+ ? <Text color="cyan">加载项目列表…</Text>
320
+ : <Select
321
+ items={[
322
+ { label: '➕ 创建新项目', value: '__create__' },
323
+ ...projects.map(p => ({ label: p.name, value: p.id, desc: p.description })),
324
+ ]}
325
+ onSelect={v => v === '__create__' ? setCreateMode('project') : pickProject(projects!.find(p => p.id === v)!)}
326
+ />}
327
+ </Box>
328
+ <Text color="gray">↑↓ 选择 · 回车确认 · Esc 取消</Text>
329
+ </Box>
330
+ ) : null}
331
+
332
+ {step === 'issues' ? (
333
+ <Box flexDirection="column">
334
+ <Text bold color="cyan">选择任务</Text>
335
+ <Text color="gray">项目: {project?.name}</Text>
336
+ <Box marginTop={1}>
337
+ {issues === null
338
+ ? <Text color="cyan">加载任务列表…</Text>
339
+ : issues.length === 0
340
+ ? <Select
341
+ items={[{ label: '➕ 创建新任务(尚无任务)', value: '__create__' }]}
342
+ onSelect={() => setCreateMode('issue')} />
343
+ : <Select
344
+ items={[
345
+ { label: '➕ 创建新任务', value: '__create__' },
346
+ ...issues.map(i => ({ label: i.title, value: i.id, desc: i.description })),
347
+ ]}
348
+ onSelect={v => v === '__create__' ? setCreateMode('issue') : pickIssue(issues!.find(i => i.id === v)!)} />}
349
+ </Box>
350
+ <Text color="gray">↑↓ 选择 · 回车确认 · Esc 取消</Text>
351
+ </Box>
352
+ ) : null}
353
+
354
+ {step === 'models' ? (
355
+ <Box flexDirection="column">
356
+ <Text bold color="cyan">选择模型</Text>
357
+ <Text color="gray">项目: {project?.name} · 任务: {issue?.title}</Text>
358
+ <Box marginTop={1}>
359
+ {models === null
360
+ ? <Text color="cyan">加载模型列表…</Text>
361
+ : models.length === 0
362
+ ? <Text color="gray">(无可用模型)</Text>
363
+ : <Select
364
+ items={models.map(o => ({
365
+ label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
366
+ value: o.key,
367
+ desc: o.sub,
368
+ }))}
369
+ onSelect={key => void pickModel(key)}
370
+ />}
371
+ </Box>
372
+ <Text color="gray">↑↓ 选择 · 回车确认 · Esc 取消</Text>
373
+ </Box>
374
+ ) : null}
375
+ </Box>
376
+ )
377
+ }
@@ -116,7 +116,14 @@ export function PrepScreen({ client, onReady, onQuit }: {
116
116
  async function createProject(name: string, description: string) {
117
117
  setStatusMsg('创建项目…')
118
118
  try {
119
- const p = await client.createProject({ name: name || '未命名项目', description, bindPath: thisCwd, defaultUseWorktree: false })
119
+ // bindPath mobius 服务器上的工作目录,不应使用 TUI 客户端的 process.cwd()
120
+ // TUI 可能运行在不同机器/OS 上(尤其是通过 aimux 连接时),
121
+ // 客户端 cwd 对服务器无意义(Windows 路径如 C:\Users\... 在 Linux 上会被误解析为相对路径)。
122
+ // 用项目名+随机后缀生成唯一服务器端子目录,由服务器 resolveBindPath 拼到用户 work_dir 下。
123
+ const slug = (name || '未命名项目').replace(/[^a-zA-Z0-9一-鿿_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 56) || 'project'
124
+ const suffix = require('crypto').randomBytes(3).toString('hex')
125
+ const safeDir = `/${slug}-${suffix}`
126
+ const p = await client.createProject({ name: name || '未命名项目', description, bindPath: safeDir, defaultUseWorktree: false })
120
127
  const list = await client.listProjects(); await saveProjectsCache(list); setProjects(list)
121
128
  await bindCwdToProject(thisCwd, p.id)
122
129
  await enterProject(p, list)
@@ -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 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.
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
- if (btn === 64) delta++
43
- else if (btn === 65) delta--
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 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--
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 delta
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 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)`.
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 wheel events.
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 useMouseWheel(onWheel: (delta: number) => void): void {
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 cbRef = useRef(onWheel)
69
- cbRef.current = onWheel
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 wheel ticks (fast scrolling) and
78
- // an SGR sequence may be split across chunks, so accumulate and re-scan.
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 delta = mouseWheelDelta(buf)
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
- // Ink labels the \x7f that virtually every terminal's Backspace key emits
141
- // as `key.delete` (see its parse-keypress.js TODO). Treat either signal as
142
- // a backward delete — otherwise Backspace at the end of the input is a no-op.
143
- if (key.backspace || key.delete || (key.ctrl && input === 'h')) {
144
- if (cursor > 0) {
145
- // delete word on Ctrl+W
146
- if (key.ctrl && input === 'w') {
147
- const before = value.slice(0, cursor)
148
- const m = before.match(/\S+\s*$/)
149
- const cut = m ? m[0].length : 0
150
- edit(value.slice(0, cursor - cut) + value.slice(cursor), cursor - cut)
151
- } else {
152
- edit(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1)
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 }