@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.
- package/package.json +3 -2
- package/src/App.tsx +29 -0
- package/src/components/Chat.tsx +270 -127
- package/src/components/ConfigFlow.tsx +377 -0
- package/src/components/PrepScreen.tsx +8 -1
- package/src/components/primitives.tsx +99 -44
- package/src/lib/delete-keys.ts +152 -0
- package/src/lib/screen-text.ts +339 -0
|
@@ -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
|
-
|
|
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
|
|
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 }
|