@mobius-os/mobius 0.3.31 → 0.3.38
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/README.md +7 -0
- package/package.json +2 -1
- package/scripts/build-python-bundles.sh +2 -2
- package/src/App.tsx +18 -2
- package/src/aimux.ts +17 -8
- package/src/components/Chat.tsx +118 -197
- package/src/components/ConfigFlow.tsx +30 -6
- package/src/components/Login.tsx +5 -3
- package/src/components/PrepScreen.tsx +98 -25
- package/src/components/primitives.tsx +44 -5
- package/src/lib/cursor-keys.ts +70 -0
- package/src/lib/delete-keys.ts +4 -4
- package/src/lib/entry-view.ts +10 -5
- package/src/lib/screen-text.ts +0 -35
- package/src/lib/transcript-viewport.ts +162 -0
- package/src/markdown.ts +34 -10
- package/src/version.ts +21 -0
- package/tests/aimux.test.tsx +17 -6
- package/tests/flow.test.tsx +25 -4
- package/tests/screen.test.tsx +11 -10
- package/tests/scroll.test.tsx +13 -12
- package/tests/selection.test.tsx +1 -1
- package/tests/ui.test.tsx +141 -10
- package/tests/viewport.test.ts +83 -0
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import React, { useEffect, useRef, useState } from 'react'
|
|
23
23
|
import { Box, Text } from 'ink'
|
|
24
|
-
import { Select, TextInput, Spinner, type SelectItem } from './primitives.js'
|
|
24
|
+
import { isEscapeKeypress, Select, TextInput, Spinner, useStableInput, type SelectItem } from './primitives.js'
|
|
25
25
|
import { MobiusClient } from '../api.js'
|
|
26
26
|
import {
|
|
27
27
|
bindCwdToProject, cwd, getCwdPreference, loadDir2Project, loadProjectsCache,
|
|
@@ -118,8 +118,8 @@ export function ConfigFlow({ client, issue, onDone }: {
|
|
|
118
118
|
: <Select
|
|
119
119
|
items={models.map(o => ({
|
|
120
120
|
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
121
|
-
value: o.key,
|
|
122
121
|
desc: o.sub,
|
|
122
|
+
value: o.key,
|
|
123
123
|
}))}
|
|
124
124
|
onSelect={key => void pickModel(key)}
|
|
125
125
|
/>}
|
|
@@ -134,9 +134,10 @@ export function ConfigFlow({ client, issue, onDone }: {
|
|
|
134
134
|
|
|
135
135
|
type ReconfigStep = 'projects' | 'issues' | 'models' | 'creating'
|
|
136
136
|
|
|
137
|
-
export function ReconfigFlow({ client, onDone }: {
|
|
137
|
+
export function ReconfigFlow({ client, onDone, onCancel }: {
|
|
138
138
|
client: MobiusClient
|
|
139
139
|
onDone: (r: ConfigResult) => void
|
|
140
|
+
onCancel: () => void
|
|
140
141
|
}) {
|
|
141
142
|
const [step, setStep] = useState<ReconfigStep>('projects')
|
|
142
143
|
const [projects, setProjects] = useState<Project[] | null>(null)
|
|
@@ -151,6 +152,29 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
151
152
|
const doneRef = useRef(false)
|
|
152
153
|
const thisCwd = cwd()
|
|
153
154
|
|
|
155
|
+
function goBack() {
|
|
156
|
+
// The focused TextInput owns Esc while a create form is open. Returning
|
|
157
|
+
// here prevents the same raw key from also navigating the underlying step.
|
|
158
|
+
if (createMode !== null) return
|
|
159
|
+
if (step === 'models') {
|
|
160
|
+
setIssue(null)
|
|
161
|
+
setStep('issues')
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if (step === 'issues') {
|
|
165
|
+
setProject(null)
|
|
166
|
+
setIssue(null)
|
|
167
|
+
setStep('projects')
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (step === 'projects') onCancel()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Own Esc across both loaded lists and their async loading frames.
|
|
174
|
+
useStableInput((input, key) => {
|
|
175
|
+
if (isEscapeKeypress(input, key)) goBack()
|
|
176
|
+
})
|
|
177
|
+
|
|
154
178
|
useEffect(() => () => { doneRef.current = true }, [])
|
|
155
179
|
|
|
156
180
|
// Load projects on mount.
|
|
@@ -320,7 +344,7 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
320
344
|
: <Select
|
|
321
345
|
items={[
|
|
322
346
|
{ label: '➕ 创建新项目', value: '__create__' },
|
|
323
|
-
...projects.map(p => ({ label: p.name,
|
|
347
|
+
...projects.map(p => ({ label: p.name, desc: p.description, value: p.id })),
|
|
324
348
|
]}
|
|
325
349
|
initialActive={projects.length > 0 ? 1 : 0}
|
|
326
350
|
onSelect={v => v === '__create__' ? setCreateMode('project') : pickProject(projects!.find(p => p.id === v)!)}
|
|
@@ -344,7 +368,7 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
344
368
|
: <Select
|
|
345
369
|
items={[
|
|
346
370
|
{ label: '➕ 创建新任务', value: '__create__' },
|
|
347
|
-
...issues.map(i => ({ label: i.title,
|
|
371
|
+
...issues.map(i => ({ label: i.title, desc: i.description, value: i.id })),
|
|
348
372
|
]}
|
|
349
373
|
initialActive={1}
|
|
350
374
|
onSelect={v => v === '__create__' ? setCreateMode('issue') : pickIssue(issues!.find(i => i.id === v)!)} />}
|
|
@@ -365,8 +389,8 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
365
389
|
: <Select
|
|
366
390
|
items={models.map(o => ({
|
|
367
391
|
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
368
|
-
value: o.key,
|
|
369
392
|
desc: o.sub,
|
|
393
|
+
value: o.key,
|
|
370
394
|
}))}
|
|
371
395
|
onSelect={key => void pickModel(key)}
|
|
372
396
|
/>}
|
package/src/components/Login.tsx
CHANGED
|
@@ -15,12 +15,14 @@ import { saveLogin, type LoginRecord } from '../config.js'
|
|
|
15
15
|
|
|
16
16
|
const DEFAULT_SERVER = ''
|
|
17
17
|
|
|
18
|
-
export function LoginScreen({ onSuccess, onError }: {
|
|
18
|
+
export function LoginScreen({ onSuccess, onError, initialServer = DEFAULT_SERVER, initialUsername = '' }: {
|
|
19
19
|
onSuccess: (rec: LoginRecord) => void
|
|
20
20
|
onError?: (msg: string) => void
|
|
21
|
+
initialServer?: string
|
|
22
|
+
initialUsername?: string
|
|
21
23
|
}) {
|
|
22
|
-
const [server, setServer] = useState(
|
|
23
|
-
const [username, setUsername] = useState(
|
|
24
|
+
const [server, setServer] = useState(initialServer)
|
|
25
|
+
const [username, setUsername] = useState(initialUsername)
|
|
24
26
|
const [password, setPassword] = useState('')
|
|
25
27
|
const [pwdRequired, setPwdRequired] = useState<boolean | null>(null)
|
|
26
28
|
const [focus, setFocus] = useState(0) // 0 server, 1 user, 2 password
|
|
@@ -230,10 +230,75 @@ function toItems(arr: { id: string; name: string; description?: string }[]): Sel
|
|
|
230
230
|
return arr.map(s => ({ label: s.name, value: s.id, desc: s.description }))
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
// Search-first picker used by the project and issue screens. The search field
|
|
234
|
+
// owns the initial focus so a user can type immediately; Down hands control to
|
|
235
|
+
// the normal Select for keyboard navigation. Enter in the search field chooses
|
|
236
|
+
// the first matching row, which keeps the common "type a unique name, Enter"
|
|
237
|
+
// workflow to one step.
|
|
238
|
+
function SearchableSelect({
|
|
239
|
+
items,
|
|
240
|
+
createItem,
|
|
241
|
+
title,
|
|
242
|
+
placeholder,
|
|
243
|
+
onSelect,
|
|
244
|
+
onCreate,
|
|
245
|
+
onQuit,
|
|
246
|
+
}: {
|
|
247
|
+
items: SelectItem[]
|
|
248
|
+
createItem?: SelectItem
|
|
249
|
+
title: string
|
|
250
|
+
placeholder: string
|
|
251
|
+
onSelect: (value: string) => void
|
|
252
|
+
onCreate?: () => void
|
|
253
|
+
onQuit?: () => void
|
|
254
|
+
}) {
|
|
255
|
+
const [query, setQuery] = useState('')
|
|
256
|
+
const [focus, setFocus] = useState<'search' | 'list'>('search')
|
|
257
|
+
const needle = query.trim().toLocaleLowerCase()
|
|
258
|
+
const matches = (item: SelectItem) => {
|
|
259
|
+
if (!needle) return true
|
|
260
|
+
return `${item.label}\n${item.desc ?? ''}\n${item.value}`.toLocaleLowerCase().includes(needle)
|
|
261
|
+
}
|
|
262
|
+
const filtered = items.filter(matches)
|
|
263
|
+
const createMatches = createItem && matches(createItem)
|
|
264
|
+
const visibleItems = createItem && (!needle || createMatches)
|
|
265
|
+
? [createItem, ...filtered]
|
|
266
|
+
: filtered
|
|
267
|
+
|
|
268
|
+
function choose(value: string) {
|
|
269
|
+
if (value === createItem?.value) onCreate?.()
|
|
270
|
+
else onSelect(value)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<Box flexDirection="column">
|
|
275
|
+
<Text bold color="cyan">{title}</Text>
|
|
276
|
+
<Box marginTop={1} flexDirection="column">
|
|
277
|
+
<TextInput
|
|
278
|
+
value={query}
|
|
279
|
+
onChange={setQuery}
|
|
280
|
+
focused={focus === 'search'}
|
|
281
|
+
prompt="搜索:"
|
|
282
|
+
placeholder={placeholder}
|
|
283
|
+
onArrowDown={() => setFocus('list')}
|
|
284
|
+
onArrowUp={() => setFocus('list')}
|
|
285
|
+
onSubmit={() => { if (visibleItems.length) choose(visibleItems[0].value) }}
|
|
286
|
+
onEscape={() => onQuit?.()}
|
|
287
|
+
/>
|
|
288
|
+
{query.trim() && !filtered.length
|
|
289
|
+
? <Text color="yellow">没有匹配的项目,请修改搜索</Text>
|
|
290
|
+
: focus === 'search' && query.trim() ? <Text color="gray">匹配 {filtered.length} 项 · ↓进入列表</Text> : null}
|
|
291
|
+
{focus === 'list' && !visibleItems.length
|
|
292
|
+
? <Text color="yellow">没有匹配的项目,请按 Esc 修改搜索</Text>
|
|
293
|
+
: <Select
|
|
294
|
+
items={visibleItems}
|
|
295
|
+
focused={focus === 'list'}
|
|
296
|
+
onBack={() => setFocus('search')}
|
|
297
|
+
onSelect={choose}
|
|
298
|
+
/>}
|
|
299
|
+
</Box>
|
|
300
|
+
</Box>
|
|
301
|
+
)
|
|
237
302
|
}
|
|
238
303
|
|
|
239
304
|
// ── Project picker ───────────────────────────────────────────────────────────
|
|
@@ -264,22 +329,25 @@ function ProjectPicker({ cwd, projects, statusMsg, onPick, onCreate, onQuit }: {
|
|
|
264
329
|
)
|
|
265
330
|
}
|
|
266
331
|
|
|
267
|
-
const items: SelectItem[] =
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}),
|
|
273
|
-
]
|
|
332
|
+
const items: SelectItem[] = projects.map(p => ({
|
|
333
|
+
label: p.name,
|
|
334
|
+
desc: p.description,
|
|
335
|
+
value: p.id,
|
|
336
|
+
}))
|
|
274
337
|
return (
|
|
275
338
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
276
|
-
<Text bold color="cyan">选择当前路径的绑定项目</Text>
|
|
277
339
|
<Text color="gray">{cwd}</Text>
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
340
|
+
<SearchableSelect
|
|
341
|
+
title="选择当前路径的绑定项目"
|
|
342
|
+
placeholder="输入项目名或描述"
|
|
343
|
+
items={items}
|
|
344
|
+
createItem={{ label: '➕ 创建新项目', value: '__create__', desc: '绑定到当前路径' }}
|
|
345
|
+
onSelect={v => onPick(projects.find(p => p.id === v)!)}
|
|
346
|
+
onCreate={() => setMode('create')}
|
|
347
|
+
onQuit={onQuit}
|
|
348
|
+
/>
|
|
281
349
|
{statusMsg ? <Text color="yellow">{statusMsg}</Text> : null}
|
|
282
|
-
<Text color="gray"
|
|
350
|
+
<Text color="gray">输入关键词筛选 · ↓进入列表 · Esc 退出</Text>
|
|
283
351
|
</Box>
|
|
284
352
|
)
|
|
285
353
|
}
|
|
@@ -306,18 +374,23 @@ function IssuePicker({ issues, onPick, onCreate }: {
|
|
|
306
374
|
</Box>
|
|
307
375
|
)
|
|
308
376
|
}
|
|
309
|
-
const items: SelectItem[] =
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
377
|
+
const items: SelectItem[] = issues.map(i => ({
|
|
378
|
+
label: i.title,
|
|
379
|
+
desc: i.description,
|
|
380
|
+
value: i.id,
|
|
381
|
+
}))
|
|
313
382
|
return (
|
|
314
383
|
<Box flexDirection="column">
|
|
315
|
-
<Text bold color="cyan">选择任务(Issue)</Text>
|
|
316
384
|
<Text color="gray">偏好设置将保存在所选任务内部</Text>
|
|
317
385
|
<Box marginTop={1}>
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
386
|
+
<SearchableSelect
|
|
387
|
+
title="选择任务(Issue)"
|
|
388
|
+
placeholder="输入任务标题或描述"
|
|
389
|
+
items={items}
|
|
390
|
+
createItem={{ label: issues.length ? '➕ 创建新任务' : '➕ 创建新任务(尚无任务)', value: '__create__' }}
|
|
391
|
+
onSelect={v => onPick(issues.find(i => i.id === v)!)}
|
|
392
|
+
onCreate={() => setMode('create-name')}
|
|
393
|
+
/>
|
|
321
394
|
</Box>
|
|
322
395
|
</Box>
|
|
323
396
|
)
|
|
@@ -332,8 +405,8 @@ function ModelPicker({ options, defaultKey, onSelect }: {
|
|
|
332
405
|
if (!options.length) return <Text color="gray">加载模型列表…</Text>
|
|
333
406
|
const items: SelectItem[] = options.map(o => ({
|
|
334
407
|
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
335
|
-
value: o.key,
|
|
336
408
|
desc: o.sub,
|
|
409
|
+
value: o.key,
|
|
337
410
|
}))
|
|
338
411
|
return (
|
|
339
412
|
<Box flexDirection="column">
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import React, { useEffect, useRef, useState } from 'react'
|
|
6
6
|
import { Box, Text, useInput, useStdout, useStdin, type Key } from 'ink'
|
|
7
|
-
import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
|
|
7
|
+
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousWordBoundary, nextWordBoundary } from '../lib/delete-keys.js'
|
|
8
|
+
import { useCursorKeyCapture } from '../lib/cursor-keys.js'
|
|
8
9
|
|
|
9
10
|
/** Return false when a mounted listener deliberately did not consume the input. */
|
|
10
11
|
type InputHandler = (input: string, key: Key) => void | false
|
|
@@ -192,6 +193,26 @@ export function parseMouseEvents(input: string): MouseEventInfo[] {
|
|
|
192
193
|
return out
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/** Collapse each contiguous wheel burst into one delta without reordering clicks. */
|
|
197
|
+
export function coalesceMouseEvents(events: MouseEventInfo[]): MouseEventInfo[] {
|
|
198
|
+
const out: MouseEventInfo[] = []
|
|
199
|
+
let wheelDelta = 0
|
|
200
|
+
const flushWheel = () => {
|
|
201
|
+
if (wheelDelta !== 0) out.push({ kind: 'wheel', delta: wheelDelta })
|
|
202
|
+
wheelDelta = 0
|
|
203
|
+
}
|
|
204
|
+
for (const event of events) {
|
|
205
|
+
if (event.kind === 'wheel') {
|
|
206
|
+
wheelDelta += event.delta
|
|
207
|
+
} else {
|
|
208
|
+
flushWheel()
|
|
209
|
+
out.push(event)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
flushWheel()
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
|
|
195
216
|
/**
|
|
196
217
|
* Enables terminal mouse tracking for the lifetime of the calling component and
|
|
197
218
|
* forwards mouse events (wheel + left-button press/motion/release) to the given
|
|
@@ -228,7 +249,7 @@ export function useMouseEvents(handlers: {
|
|
|
228
249
|
// A single read() chunk may carry several events and a sequence may be
|
|
229
250
|
// split across chunks, so accumulate and re-scan.
|
|
230
251
|
buf += String(chunk)
|
|
231
|
-
for (const e of parseMouseEvents(buf)) {
|
|
252
|
+
for (const e of coalesceMouseEvents(parseMouseEvents(buf))) {
|
|
232
253
|
if (e.kind === 'wheel') refs.current.onWheel?.(e.delta)
|
|
233
254
|
else if (e.kind === 'press') refs.current.onPress?.(e.row, e.col)
|
|
234
255
|
else if (e.kind === 'motion') refs.current.onMotion?.(e.row, e.col)
|
|
@@ -298,6 +319,14 @@ export function TextInput(props: TextInputProps) {
|
|
|
298
319
|
const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
|
|
299
320
|
edit(text, nextCursor)
|
|
300
321
|
})
|
|
322
|
+
useCursorKeyCapture(focused, (intent) => {
|
|
323
|
+
const current = valueRef.current
|
|
324
|
+
const at = clampCursor(current, cursorRef.current)
|
|
325
|
+
const next = intent === 'home' ? 0 : intent === 'end' ? current.length
|
|
326
|
+
: intent === 'backward-word' ? previousWordBoundary(current, at) : nextWordBoundary(current, at)
|
|
327
|
+
cursorRef.current = next
|
|
328
|
+
setCursor(next)
|
|
329
|
+
})
|
|
301
330
|
|
|
302
331
|
useStableInput((input, key) => {
|
|
303
332
|
if (isMouseInput(input)) return
|
|
@@ -323,6 +352,7 @@ export function TextInput(props: TextInputProps) {
|
|
|
323
352
|
edit(text, nextCursor)
|
|
324
353
|
return
|
|
325
354
|
}
|
|
355
|
+
if (key.ctrl && (key.leftArrow || key.rightArrow)) return
|
|
326
356
|
if (key.leftArrow) { setCursor(c => Math.max(0, c - 1)); return }
|
|
327
357
|
if (key.rightArrow) { setCursor(c => Math.min(value.length, c + 1)); return }
|
|
328
358
|
if (key.ctrl && input === 'a') { setCursor(0); return }
|
|
@@ -401,6 +431,12 @@ export interface SelectItem {
|
|
|
401
431
|
desc?: string
|
|
402
432
|
}
|
|
403
433
|
|
|
434
|
+
/** Keep a picker explanation on the item's main row; Select truncates that row. */
|
|
435
|
+
export function inlineSelectLabel(label: string, detail?: string): string {
|
|
436
|
+
const oneLine = detail?.replace(/\s*\n\s*/g, ' ⏎ ').replace(/[ \t]+/g, ' ').trim()
|
|
437
|
+
return oneLine ? `${label} - ${oneLine}` : label
|
|
438
|
+
}
|
|
439
|
+
|
|
404
440
|
export interface SelectProps {
|
|
405
441
|
items: SelectItem[]
|
|
406
442
|
mode?: 'single' | 'multi'
|
|
@@ -471,17 +507,20 @@ export function Select(props: SelectProps) {
|
|
|
471
507
|
const isActive = realIdx === active
|
|
472
508
|
const checked = mode === 'multi' ? selectedSet.has(it.value) : false
|
|
473
509
|
const marker = mode === 'multi' ? (checked ? '☑' : '☐') : isActive ? '❯' : ' '
|
|
510
|
+
// Keep picker rows compact: descriptions belong on the highlighted
|
|
511
|
+
// row only. Unfocused rows show just their label so a long list does
|
|
512
|
+
// not turn every item into a multi-line block.
|
|
513
|
+
const rowLabel = isActive ? inlineSelectLabel(it.label, it.desc) : it.label
|
|
474
514
|
return (
|
|
475
|
-
<Box key={it.value}
|
|
515
|
+
<Box key={it.value}>
|
|
476
516
|
<Text
|
|
477
517
|
color={isActive ? 'black' : undefined}
|
|
478
518
|
backgroundColor={isActive ? 'cyan' : undefined}
|
|
479
519
|
bold={isActive}
|
|
480
520
|
wrap="truncate-end"
|
|
481
521
|
>
|
|
482
|
-
{marker} {
|
|
522
|
+
{marker} {rowLabel}
|
|
483
523
|
</Text>
|
|
484
|
-
{isActive && it.desc ? <Text color="gray" wrap="truncate-end"> {it.desc}</Text> : null}
|
|
485
524
|
</Box>
|
|
486
525
|
)
|
|
487
526
|
})}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Raw terminal cursor-key capture for keys Ink does not expose in `Key`. */
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { useStdin } from 'ink'
|
|
5
|
+
|
|
6
|
+
export type CursorIntent = 'home' | 'end' | 'backward-word' | 'forward-word'
|
|
7
|
+
|
|
8
|
+
// Home/End vary by terminal/application mode. Ctrl+Left/Right are the xterm
|
|
9
|
+
// CSI modifier-5 forms; the shorter 5D/5C forms are emitted by some tmux and
|
|
10
|
+
// ConPTY bridges. Longest sequences come first so prefixes cannot win early.
|
|
11
|
+
const CURSOR_SEQUENCES: ReadonlyArray<readonly [string, CursorIntent]> = [
|
|
12
|
+
['\x1b[1;5D', 'backward-word'],
|
|
13
|
+
['\x1b[1;5C', 'forward-word'],
|
|
14
|
+
['\x1b[5D', 'backward-word'],
|
|
15
|
+
['\x1b[5C', 'forward-word'],
|
|
16
|
+
['\x1b[1~', 'home'],
|
|
17
|
+
['\x1b[7~', 'home'],
|
|
18
|
+
['\x1b[4~', 'end'],
|
|
19
|
+
['\x1b[8~', 'end'],
|
|
20
|
+
['\x1b[H', 'home'],
|
|
21
|
+
['\x1b[F', 'end'],
|
|
22
|
+
['\x1bOH', 'home'],
|
|
23
|
+
['\x1bOF', 'end'],
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export function classifyCursorSequence(raw: string): { intent: CursorIntent; length: number } | null {
|
|
27
|
+
for (const [sequence, intent] of CURSOR_SEQUENCES) {
|
|
28
|
+
if (raw.startsWith(sequence)) return { intent, length: sequence.length }
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isCursorPrefix(raw: string): boolean {
|
|
34
|
+
return CURSOR_SEQUENCES.some(([sequence]) => sequence.startsWith(raw))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Capture physical Home/End and Ctrl+arrow bytes without relying on Ink's lossy key object. */
|
|
38
|
+
export function useCursorKeyCapture(
|
|
39
|
+
enabled: boolean,
|
|
40
|
+
onCursor: (intent: CursorIntent) => void,
|
|
41
|
+
): void {
|
|
42
|
+
const { internal_eventEmitter } = useStdin()
|
|
43
|
+
const enabledRef = useRef(enabled)
|
|
44
|
+
const onCursorRef = useRef(onCursor)
|
|
45
|
+
enabledRef.current = enabled
|
|
46
|
+
onCursorRef.current = onCursor
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!internal_eventEmitter) return
|
|
50
|
+
let buf = ''
|
|
51
|
+
const handler = (chunk: unknown) => {
|
|
52
|
+
buf += String(chunk)
|
|
53
|
+
while (buf) {
|
|
54
|
+
const match = classifyCursorSequence(buf)
|
|
55
|
+
if (match) {
|
|
56
|
+
buf = buf.slice(match.length)
|
|
57
|
+
if (enabledRef.current) onCursorRef.current(match.intent)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (isCursorPrefix(buf)) break
|
|
61
|
+
// Ordinary text is handled by Ink's useInput; only retain a possible
|
|
62
|
+
// incomplete escape prefix so split terminal sequences still match.
|
|
63
|
+
const nextEsc = buf.indexOf('\x1b', buf.startsWith('\x1b') ? 1 : 0)
|
|
64
|
+
buf = nextEsc >= 0 ? buf.slice(nextEsc) : ''
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
internal_eventEmitter.on('input', handler)
|
|
68
|
+
return () => { internal_eventEmitter.off('input', handler) }
|
|
69
|
+
}, [internal_eventEmitter])
|
|
70
|
+
}
|
package/src/lib/delete-keys.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function nextCursorBoundary(text: string, cursor: number): number {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/** Backward-word boundary: skip trailing whitespace, then the word before it. */
|
|
62
|
-
function
|
|
62
|
+
export function previousWordBoundary(text: string, at: number): number {
|
|
63
63
|
let i = at
|
|
64
64
|
while (i > 0 && /\s/.test(text[i - 1])) i--
|
|
65
65
|
while (i > 0 && !/\s/.test(text[i - 1])) i--
|
|
@@ -67,7 +67,7 @@ function backwardWordBoundary(text: string, at: number): number {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** Forward-word boundary: skip leading whitespace, then the word after it. */
|
|
70
|
-
function
|
|
70
|
+
export function nextWordBoundary(text: string, at: number): number {
|
|
71
71
|
let i = at
|
|
72
72
|
while (i < text.length && /\s/.test(text[i])) i++
|
|
73
73
|
while (i < text.length && !/\s/.test(text[i])) i++
|
|
@@ -93,12 +93,12 @@ export function applyDeleteIntent(
|
|
|
93
93
|
return { text: text.slice(0, at) + text.slice(next), cursor: at }
|
|
94
94
|
}
|
|
95
95
|
case 'backward-word': {
|
|
96
|
-
const start =
|
|
96
|
+
const start = previousWordBoundary(text, at)
|
|
97
97
|
if (start === at) return { text, cursor: at }
|
|
98
98
|
return { text: text.slice(0, start) + text.slice(at), cursor: start }
|
|
99
99
|
}
|
|
100
100
|
case 'forward-word': {
|
|
101
|
-
const end =
|
|
101
|
+
const end = nextWordBoundary(text, at)
|
|
102
102
|
if (end === at) return { text, cursor: at }
|
|
103
103
|
return { text: text.slice(0, at) + text.slice(end), cursor: at }
|
|
104
104
|
}
|
package/src/lib/entry-view.ts
CHANGED
|
@@ -638,18 +638,18 @@ export function toolLabel(name: string): string {
|
|
|
638
638
|
// agent 输出, 则视为同一次输入的重复入口 → 丢弃, 避免 TUI 把同一条提问显示多次.
|
|
639
639
|
export function userTextOf(e: AnyEntry): string {
|
|
640
640
|
if (e?.type === 'event_msg' && e?.payload?.type === 'user_message') {
|
|
641
|
-
return String(e?.payload?.message || '')
|
|
641
|
+
return canonicalUserText(String(e?.payload?.message || ''))
|
|
642
642
|
}
|
|
643
643
|
if (e?.type === 'response_item' && e?.payload?.type === 'message' && e?.payload?.role === 'user') {
|
|
644
644
|
const c = e?.payload?.content
|
|
645
|
-
if (typeof c === 'string') return c
|
|
646
|
-
if (Array.isArray(c)) return c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n')
|
|
645
|
+
if (typeof c === 'string') return canonicalUserText(c)
|
|
646
|
+
if (Array.isArray(c)) return canonicalUserText(c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n'))
|
|
647
647
|
return ''
|
|
648
648
|
}
|
|
649
649
|
if (e?.type === 'user') {
|
|
650
650
|
const c = e?.message?.content
|
|
651
|
-
if (typeof c === 'string') return c
|
|
652
|
-
if (Array.isArray(c)) return c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n')
|
|
651
|
+
if (typeof c === 'string') return canonicalUserText(c)
|
|
652
|
+
if (Array.isArray(c)) return canonicalUserText(c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n'))
|
|
653
653
|
return ''
|
|
654
654
|
}
|
|
655
655
|
return ''
|
|
@@ -669,6 +669,11 @@ export function stripUserFraming(text: string): string {
|
|
|
669
669
|
return after || text
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
+
/** Canonical user text for duplicate event identities (framed vs plain). */
|
|
673
|
+
function canonicalUserText(text: string): string {
|
|
674
|
+
return stripUserFraming(text).replace(/\s+/g, ' ').trim()
|
|
675
|
+
}
|
|
676
|
+
|
|
672
677
|
export function isAssistantOutput(e: AnyEntry): boolean {
|
|
673
678
|
if (e?.type === 'assistant') return true
|
|
674
679
|
if (e?.type === 'event_msg' && e?.payload?.type === 'agent_message') return true
|
package/src/lib/screen-text.ts
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import wrapAnsi from 'wrap-ansi'
|
|
14
14
|
import { renderMarkdownLines } from '../markdown.js'
|
|
15
15
|
import { toolLabel, viewsForEntry, type EntryView } from './entry-view.js'
|
|
16
|
-
import type { AnyEntry } from '../types.js'
|
|
17
16
|
|
|
18
17
|
// ── shared text helpers (mirrored from Chat.tsx, kept here to avoid a cycle) ─
|
|
19
18
|
export function displayWidth(str: string): number {
|
|
@@ -221,11 +220,6 @@ export function entryScreenRows(views: EntryView[], columns: number): ScreenRow[
|
|
|
221
220
|
return lines
|
|
222
221
|
}
|
|
223
222
|
|
|
224
|
-
/** Plain projection used by fitting, geometry, hit-testing, and copying. */
|
|
225
|
-
export function entryScreenLines(views: EntryView[], columns: number): string[] {
|
|
226
|
-
return entryScreenRows(views, columns).map(row => row.plain)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
223
|
// ── vertical geometry ────────────────────────────────────────────────────────
|
|
230
224
|
export interface TranscriptGeometry {
|
|
231
225
|
/** Screen row where the transcript box's top edge sits. */
|
|
@@ -240,30 +234,6 @@ export interface TranscriptGeometry {
|
|
|
240
234
|
* (`flexShrink=0`); the middle column holds header + hint + transcript (flexGrow)
|
|
241
235
|
* + tip + help. Margins that render as extra rows are counted explicitly.
|
|
242
236
|
*/
|
|
243
|
-
export function computeTranscriptGeometry(opts: {
|
|
244
|
-
viewportRows: number
|
|
245
|
-
composerRows: number
|
|
246
|
-
statusRows: number
|
|
247
|
-
activityRows: number
|
|
248
|
-
helpRows: number
|
|
249
|
-
showWelcome: boolean
|
|
250
|
-
welcomeRows: number
|
|
251
|
-
olderHintShown: boolean
|
|
252
|
-
tipShown: boolean
|
|
253
|
-
}): TranscriptGeometry {
|
|
254
|
-
// The composer's reported height already includes its marginTop; the status
|
|
255
|
-
// area and working indicator rows are already folded into statusRows and
|
|
256
|
-
// activityRows. No extra +1 here — calibrated against the rendered frame.
|
|
257
|
-
const bottomH = opts.activityRows + opts.composerRows + opts.statusRows
|
|
258
|
-
const midH = opts.viewportRows - bottomH
|
|
259
|
-
const headerH = opts.showWelcome ? opts.welcomeRows : 1
|
|
260
|
-
const hintH = opts.olderHintShown ? 1 : 0
|
|
261
|
-
const tipH = opts.tipShown ? 2 : 0 // marginTop 1 + content 1
|
|
262
|
-
const helpH = opts.helpRows > 0 ? opts.helpRows + 1 : 0 // +1 marginTop
|
|
263
|
-
const boxTop = headerH + hintH + tipH + helpH
|
|
264
|
-
return { boxTop, boxH: Math.max(0, midH - boxTop) }
|
|
265
|
-
}
|
|
266
|
-
|
|
267
237
|
// ── selection mapping ────────────────────────────────────────────────────────
|
|
268
238
|
export interface SelPoint {
|
|
269
239
|
entry: number // index into the fitted entries
|
|
@@ -276,11 +246,6 @@ export interface TranscriptModel {
|
|
|
276
246
|
totalRows: number
|
|
277
247
|
}
|
|
278
248
|
|
|
279
|
-
export function buildTranscriptModel(fittedEntries: AnyEntry[], columns: number): TranscriptModel {
|
|
280
|
-
const entries = fittedEntries.map((e) => entryScreenLines(viewsForEntry(e), columns))
|
|
281
|
-
return { entries, totalRows: entries.reduce((sum, l) => sum + l.length, 0) }
|
|
282
|
-
}
|
|
283
|
-
|
|
284
249
|
/** Convert a screen (row, col) into a SelPoint, or null if outside the transcript. */
|
|
285
250
|
export function screenToSelPoint(
|
|
286
251
|
screenRow: number,
|