@mobius-os/mobius 0.3.26 → 0.3.31
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/install.ps1 +265 -0
- package/package.json +23 -7
- package/scripts/build-python-bundles.sh +129 -0
- package/src/App.tsx +6 -0
- package/src/components/Chat.tsx +125 -189
- package/src/components/ConfigFlow.tsx +2 -0
- package/src/components/primitives.tsx +123 -5
- package/src/lib/screen-text.ts +73 -27
- package/src/lib/windows-input.ts +186 -0
- package/src/main.tsx +5 -1
- package/tests/aimux.test.tsx +210 -0
- package/tests/flow.test.tsx +211 -0
- package/tests/integration.test.ts +114 -0
- package/tests/preview.tsx +104 -0
- package/tests/reconnect.test.tsx +170 -0
- package/tests/resume.test.tsx +73 -0
- package/tests/screen.test.tsx +117 -0
- package/tests/scroll.test.tsx +252 -0
- package/tests/selection.test.tsx +219 -0
- package/tests/ui.test.tsx +889 -0
- package/tsconfig.json +19 -0
- package/uninstall.ps1 +144 -0
package/src/components/Chat.tsx
CHANGED
|
@@ -8,23 +8,22 @@
|
|
|
8
8
|
* activity, composer, and a persistent context status line.
|
|
9
9
|
*/
|
|
10
10
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
11
|
-
import { Box, Text,
|
|
11
|
+
import { Box, Text, useStdout } from 'ink'
|
|
12
12
|
import { useChat } from '../hooks/useChat.js'
|
|
13
13
|
import { MobiusClient } from '../api.js'
|
|
14
|
-
import {
|
|
15
|
-
import { viewsForEntry, dedupeUserEntries, toolLabel, isAssistantOutput, type EntryView } from '../lib/entry-view.js'
|
|
14
|
+
import { viewsForEntry, dedupeUserEntries, isAssistantOutput } from '../lib/entry-view.js'
|
|
16
15
|
import {
|
|
17
|
-
|
|
16
|
+
displayWidth, compareSel, entryScreenLines, entryScreenRows,
|
|
18
17
|
buildTranscriptModel, computeTranscriptGeometry, screenToSelPoint,
|
|
19
18
|
buildSelectionMap, buildSelectionText, osc52,
|
|
20
|
-
type TranscriptModel, type TranscriptGeometry, type SelPoint,
|
|
19
|
+
type TranscriptModel, type TranscriptGeometry, type SelPoint, type ScreenRow,
|
|
21
20
|
} from '../lib/screen-text.js'
|
|
22
21
|
import type { ReadyState } from './PrepScreen.js'
|
|
23
22
|
import type { AnyEntry } from '../types.js'
|
|
24
23
|
import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
|
|
25
24
|
import type { AimuxStatus } from '../aimux.js'
|
|
26
25
|
import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
|
|
27
|
-
import { isEscapeKeypress, isMouseInput, useMouseEvents } from './primitives.js'
|
|
26
|
+
import { isEscapeKeypress, isMouseInput, useMouseEvents, useStableInput } from './primitives.js'
|
|
28
27
|
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary } from '../lib/delete-keys.js'
|
|
29
28
|
|
|
30
29
|
interface ChatProps {
|
|
@@ -68,6 +67,11 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
68
67
|
const [modelLabel, setModelLabel] = useState<string | null>(null)
|
|
69
68
|
const [configOpen, setConfigOpen] = useState(false)
|
|
70
69
|
const [reconfigOpen, setReconfigOpen] = useState(false)
|
|
70
|
+
// Ink may deliver one final event to Composer while an async config picker is
|
|
71
|
+
// replacing it. The shared ref lets that stale listener report "not handled"
|
|
72
|
+
// so App can replay the key after the new Select mounts.
|
|
73
|
+
const chatInputActiveRef = useRef(true)
|
|
74
|
+
chatInputActiveRef.current = !configOpen && !reconfigOpen
|
|
71
75
|
// Ink's useInput keeps whatever handler was registered at subscription time;
|
|
72
76
|
// reading mutable refs (updated every render) keeps the callback from acting
|
|
73
77
|
// on a stale `configOpen`/sessionId closure after the config flow opens.
|
|
@@ -112,6 +116,16 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
112
116
|
// (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
|
|
113
117
|
// 避免在累积视图里把同一条提问显示多次.
|
|
114
118
|
const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
|
|
119
|
+
// Cache: entry index → rendered line count. Cleared when entries or columns change.
|
|
120
|
+
const entryLines = useRef<Map<number, number>>(new Map())
|
|
121
|
+
useEffect(() => { entryLines.current.clear() }, [dedupedEntries, terminal.columns])
|
|
122
|
+
const getEntryLines = useCallback((i: number) => {
|
|
123
|
+
const c = entryLines.current.get(i)
|
|
124
|
+
if (c !== undefined) return c
|
|
125
|
+
const n = entryScreenLines(viewsForEntry(dedupedEntries[i]), terminal.columns).length || 1
|
|
126
|
+
entryLines.current.set(i, n)
|
|
127
|
+
return n
|
|
128
|
+
}, [dedupedEntries, terminal.columns])
|
|
115
129
|
const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
|
|
116
130
|
const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
|
|
117
131
|
const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
|
|
@@ -149,7 +163,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
149
163
|
}, [client, ready.prefs.model])
|
|
150
164
|
const modelDisplay = modelLabel ?? ready.prefs.model ?? 'default'
|
|
151
165
|
|
|
152
|
-
|
|
166
|
+
useStableInput((_input, key) => {
|
|
153
167
|
// While a config/reconfig flow is open, this ChatScreen-level handler owns
|
|
154
168
|
// Esc so cancel is reliable even mid-list-loading (a per-component
|
|
155
169
|
// EscToCancel could be unmounted by the loading→loaded transition and drop
|
|
@@ -163,7 +177,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
163
177
|
const step = Math.max(1, fitted.entries.length)
|
|
164
178
|
if (key.pageUp) setScrollBack(value => Math.min(dedupedEntries.length, value + step))
|
|
165
179
|
else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
|
|
166
|
-
})
|
|
180
|
+
}, { interactive: false })
|
|
167
181
|
|
|
168
182
|
const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
|
|
169
183
|
? fitted.hiddenOlder > 0
|
|
@@ -214,8 +228,24 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
214
228
|
useMouseEvents({
|
|
215
229
|
onWheel: (delta) => {
|
|
216
230
|
if (delta === 0) return
|
|
217
|
-
const
|
|
218
|
-
|
|
231
|
+
const targetLines = 2
|
|
232
|
+
let lines = 0
|
|
233
|
+
let next = scrollBack
|
|
234
|
+
const n = dedupedEntries.length
|
|
235
|
+
if (delta > 0) {
|
|
236
|
+
// scroll up (older): hide more entries from the tail
|
|
237
|
+
for (let i = n - 1 - next; i >= 0 && lines < targetLines; i--) {
|
|
238
|
+
lines += getEntryLines(i)
|
|
239
|
+
next++
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// scroll down (newer): unhide entries from the tail
|
|
243
|
+
for (let i = n - next; i < n && lines < targetLines; i++) {
|
|
244
|
+
lines += getEntryLines(i)
|
|
245
|
+
next--
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
setScrollBack(Math.min(n, Math.max(0, next)))
|
|
219
249
|
},
|
|
220
250
|
onPress: (row, col) => {
|
|
221
251
|
const p = screenToSelPoint(row, col, transcriptModel, geometry)
|
|
@@ -303,19 +333,21 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
303
333
|
: null}
|
|
304
334
|
|
|
305
335
|
<Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome || fitted.hiddenOlder > 0 ? 'flex-start' : 'flex-end'} overflowY="hidden">
|
|
306
|
-
{fitted.
|
|
336
|
+
{fitted.peekRows.length > 0
|
|
307
337
|
? <Box width="100%" flexShrink={0} flexDirection="column">
|
|
308
|
-
{fitted.
|
|
309
|
-
<
|
|
338
|
+
{fitted.peekRows.map((row, index) => (
|
|
339
|
+
<ScreenText
|
|
340
|
+
key={`peek-${index}`}
|
|
341
|
+
row={row}
|
|
342
|
+
text={`${index === 0 ? ' ⋯ ' : ' '}${row.styled}`}
|
|
343
|
+
/>
|
|
310
344
|
))}
|
|
311
345
|
</Box>
|
|
312
346
|
: null}
|
|
313
347
|
{fitted.entries.map((entry, index) => {
|
|
314
348
|
const entrySel = selMap?.get(index)
|
|
315
349
|
const key = entry.__id ?? `entry-${fitted.startIndex + index}`
|
|
316
|
-
return entrySel
|
|
317
|
-
? <EntryScreenWithSelection key={key} entry={entry} columns={terminal.columns} sel={entrySel} />
|
|
318
|
-
: <EntryAccum key={key} entry={entry} columns={terminal.columns} />
|
|
350
|
+
return <EntryScreen key={key} entry={entry} columns={terminal.columns} sel={entrySel} />
|
|
319
351
|
})}
|
|
320
352
|
{chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
|
|
321
353
|
{fitted.hiddenRecent > 0
|
|
@@ -341,6 +373,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
341
373
|
typing={chat.typing}
|
|
342
374
|
commands={SLASH_COMMANDS}
|
|
343
375
|
onHeightChange={setComposerRows}
|
|
376
|
+
inputActiveRef={chatInputActiveRef}
|
|
344
377
|
/>
|
|
345
378
|
<StatusArea
|
|
346
379
|
ready={ready}
|
|
@@ -419,149 +452,83 @@ function CompactHeader({ ready, sessionId, columns }: { ready: ReadyState; sessi
|
|
|
419
452
|
)
|
|
420
453
|
}
|
|
421
454
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
</Box>
|
|
428
|
-
)
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// While a drag-selection is active, the affected entries are re-rendered from the
|
|
432
|
-
// screen-text model (plain rows, no ANSI) so the selected char range can be
|
|
433
|
-
// painted with a background — the same rows the model produces, keeping the
|
|
434
|
-
// layout stable. Rows outside the selection keep their original text.
|
|
435
|
-
function EntryScreenWithSelection({ entry, columns, sel }: {
|
|
455
|
+
// Normal and selected transcript rows use the exact same component tree. Mouse
|
|
456
|
+
// motion now changes only ANSI background bytes inside a row; it never swaps a
|
|
457
|
+
// rich Markdown entry for a structurally different plain-text entry, which used
|
|
458
|
+
// to make long/styled messages jump as the selection crossed entry boundaries.
|
|
459
|
+
function EntryScreen({ entry, columns, sel }: {
|
|
436
460
|
entry: AnyEntry
|
|
437
461
|
columns: number
|
|
438
|
-
sel
|
|
462
|
+
sel?: Map<number, { start: number; end: number }>
|
|
439
463
|
}) {
|
|
440
|
-
const
|
|
464
|
+
const rows = entryScreenRows(viewsForEntry(entry), columns)
|
|
441
465
|
return (
|
|
442
466
|
<Box flexDirection="column">
|
|
443
|
-
{
|
|
444
|
-
const range = sel
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
<Text backgroundColor="cyan" color="black">{row.slice(range.start, range.end)}</Text>
|
|
450
|
-
<Text>{row.slice(range.end)}</Text>
|
|
451
|
-
</Text>
|
|
452
|
-
)
|
|
453
|
-
}
|
|
454
|
-
return <Text key={index} wrap="truncate-end">{row || ' '}</Text>
|
|
467
|
+
{rows.map((row, index) => {
|
|
468
|
+
const range = sel?.get(index)
|
|
469
|
+
const text = range && range.start < range.end
|
|
470
|
+
? highlightScreenRow(row.styled, range.start, range.end)
|
|
471
|
+
: row.styled
|
|
472
|
+
return <ScreenText key={index} row={row} text={text || ' '} />
|
|
455
473
|
})}
|
|
456
474
|
</Box>
|
|
457
475
|
)
|
|
458
476
|
}
|
|
459
477
|
|
|
460
|
-
function
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
+
function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
|
|
479
|
+
const tone = row.tone
|
|
480
|
+
const color = tone === 'tool' ? 'cyan'
|
|
481
|
+
: tone === 'tool_error' || tone === 'edit_old' || tone === 'error' ? 'red'
|
|
482
|
+
: tone === 'edit_header' || tone === 'reasoning' ? 'magenta'
|
|
483
|
+
: tone === 'edit_new' ? 'green'
|
|
484
|
+
: tone === 'system' ? 'yellow'
|
|
485
|
+
: undefined
|
|
486
|
+
const dimColor = tone === 'tool_result' || tone === 'tool_error' || tone === 'reasoning' || tone === 'system'
|
|
487
|
+
return <Text wrap="truncate-end" bold={tone === 'user'} dimColor={dimColor} color={color}>{text}</Text>
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const ANSI_CSI_RE = /^\x1b\[[0-?]*[ -/]*[@-~]/
|
|
491
|
+
const SELECTION_BG = '\x1b[46m'
|
|
492
|
+
const SELECTION_BG_END = '\x1b[49m'
|
|
493
|
+
|
|
494
|
+
/** Add a background to visible UTF-16 offsets without stripping existing ANSI. */
|
|
495
|
+
function highlightScreenRow(styled: string, start: number, end: number): string {
|
|
496
|
+
if (start >= end) return styled
|
|
497
|
+
let out = ''
|
|
498
|
+
let raw = 0
|
|
499
|
+
let visible = 0
|
|
500
|
+
let highlighted = false
|
|
501
|
+
while (raw < styled.length) {
|
|
502
|
+
if (styled.charCodeAt(raw) === 0x1b) {
|
|
503
|
+
const match = ANSI_CSI_RE.exec(styled.slice(raw))
|
|
504
|
+
if (match) {
|
|
505
|
+
out += match[0]
|
|
506
|
+
raw += match[0].length
|
|
507
|
+
// A full SGR reset inside Markdown/syntax text also resets the injected
|
|
508
|
+
// background; immediately restore it while the selected span is active.
|
|
509
|
+
if (highlighted && /^\x1b\[(?:0)?m$/.test(match[0])) out += SELECTION_BG
|
|
510
|
+
continue
|
|
511
|
+
}
|
|
478
512
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
{view.result ? (
|
|
486
|
-
<Text dimColor color={view.result.isError ? 'red' : undefined}>
|
|
487
|
-
{' └ '}{clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}
|
|
488
|
-
</Text>
|
|
489
|
-
) : null}
|
|
490
|
-
</Box>
|
|
491
|
-
)
|
|
513
|
+
const codePoint = styled.codePointAt(raw)!
|
|
514
|
+
const char = String.fromCodePoint(codePoint)
|
|
515
|
+
const nextVisible = visible + char.length
|
|
516
|
+
if (!highlighted && nextVisible > start && visible < end) {
|
|
517
|
+
out += SELECTION_BG
|
|
518
|
+
highlighted = true
|
|
492
519
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const lines = headTailLines(view.text, width - 4, 5)
|
|
497
|
-
return (
|
|
498
|
-
<Box flexDirection="column">
|
|
499
|
-
{lines.map((l, i) => (
|
|
500
|
-
<Text key={i} dimColor color={view.isError ? 'red' : undefined}>{i === 0 ? ' └ ' : ' '}{l}</Text>
|
|
501
|
-
))}
|
|
502
|
-
</Box>
|
|
503
|
-
)
|
|
520
|
+
if (highlighted && visible >= end) {
|
|
521
|
+
out += SELECTION_BG_END
|
|
522
|
+
highlighted = false
|
|
504
523
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
return <WriteFileView view={view} />
|
|
509
|
-
case 'reasoning': {
|
|
510
|
-
const lines = clampLines(view.text, width - 4, 2)
|
|
511
|
-
return (
|
|
512
|
-
<Box marginTop={1} flexDirection="column">
|
|
513
|
-
{lines.map((line, i) => (
|
|
514
|
-
<Text key={i} dimColor color="magenta">{i === 0 ? ' ◇ ' : ' '}{line}</Text>
|
|
515
|
-
))}
|
|
516
|
-
</Box>
|
|
517
|
-
)
|
|
518
|
-
}
|
|
519
|
-
case 'system':
|
|
520
|
-
return <Text dimColor color="yellow"> {clampLines(view.text, width - 2, 2)[0]}</Text>
|
|
521
|
-
case 'error':
|
|
522
|
-
return (
|
|
523
|
-
<Box marginTop={1} flexDirection="column">
|
|
524
|
-
{view.text.split('\n').map((line, i) => (
|
|
525
|
-
<Text key={i} color="red">{i === 0 ? '⚠ ' : ' '}{line}</Text>
|
|
526
|
-
))}
|
|
527
|
-
</Box>
|
|
528
|
-
)
|
|
529
|
-
default:
|
|
530
|
-
return null
|
|
524
|
+
out += char
|
|
525
|
+
raw += char.length
|
|
526
|
+
visible = nextVisible
|
|
531
527
|
}
|
|
528
|
+
if (highlighted) out += SELECTION_BG_END
|
|
529
|
+
return out
|
|
532
530
|
}
|
|
533
531
|
|
|
534
|
-
// 代码修改 (Edit/StrReplace/apply_patch) — full: 完整展示 old(−)/new(+) 改动原文.
|
|
535
|
-
function CodeEditView({ view }: { view: { filePath: string; oldString: string; newString: string } }) {
|
|
536
|
-
return (
|
|
537
|
-
<Box marginTop={1} flexDirection="column">
|
|
538
|
-
<Text color="magenta">✎ 编辑 {view.filePath || '(未指定文件)'}</Text>
|
|
539
|
-
{view.oldString ? view.oldString.split('\n').map((line, i) => (
|
|
540
|
-
<Text key={`o${i}`} color="red">{' − '}{line}</Text>
|
|
541
|
-
)) : null}
|
|
542
|
-
{view.newString ? view.newString.split('\n').map((line, i) => (
|
|
543
|
-
<Text key={`n${i}`} color="green">{' + '}{line}</Text>
|
|
544
|
-
)) : null}
|
|
545
|
-
</Box>
|
|
546
|
-
)
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// 文件写入 (Write/create_file) — full: 完整展示写入内容原文.
|
|
550
|
-
function WriteFileView({ view }: { view: { filePath: string; content: string } }) {
|
|
551
|
-
return (
|
|
552
|
-
<Box marginTop={1} flexDirection="column">
|
|
553
|
-
<Text color="magenta">✎ 写入 {view.filePath || '(未指定文件)'}</Text>
|
|
554
|
-
{view.content.split('\n').map((line, i) => (
|
|
555
|
-
<Text key={i} color="green">{' + '}{line}</Text>
|
|
556
|
-
))}
|
|
557
|
-
</Box>
|
|
558
|
-
)
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// clampLines / headTailLines / displayWidth live in src/lib/screen-text.ts
|
|
562
|
-
// (mirrored, exported) and are imported above; they must match ViewLine exactly
|
|
563
|
-
// so the drag-selection text model aligns with the rendered rows.
|
|
564
|
-
|
|
565
532
|
function UserLine({ text }: { text: string }) {
|
|
566
533
|
const lines = text.split('\n')
|
|
567
534
|
if (lines[0] !== undefined) lines[0] = `› ${lines[0]}`
|
|
@@ -627,9 +594,10 @@ interface ComposerProps {
|
|
|
627
594
|
typing: boolean
|
|
628
595
|
commands: { cmd: string; desc: string }[]
|
|
629
596
|
onHeightChange?: (rows: number) => void
|
|
597
|
+
inputActiveRef?: React.RefObject<boolean>
|
|
630
598
|
}
|
|
631
599
|
|
|
632
|
-
export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange }: ComposerProps) {
|
|
600
|
+
export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange, inputActiveRef }: ComposerProps) {
|
|
633
601
|
const [value, setValue] = useState('')
|
|
634
602
|
const [cursor, setCursor] = useState(0)
|
|
635
603
|
const [popupIdx, setPopupIdx] = useState(0)
|
|
@@ -772,7 +740,8 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
772
740
|
|
|
773
741
|
useEffect(() => () => resetPasteBurst(), [])
|
|
774
742
|
|
|
775
|
-
|
|
743
|
+
useStableInput((input, key) => {
|
|
744
|
+
if (inputActiveRef?.current === false) return false
|
|
776
745
|
if (isMouseInput(input)) return // mouse events must never become typed text
|
|
777
746
|
const now = Date.now()
|
|
778
747
|
const escape = isEscapeKeypress(input, key)
|
|
@@ -979,7 +948,7 @@ function normalizeComposerPaste(text: string): string {
|
|
|
979
948
|
}
|
|
980
949
|
|
|
981
950
|
function isEnhancedNewlineInput(input: string): boolean {
|
|
982
|
-
return /^\[(
|
|
951
|
+
return /^\[13;2u$/.test(input) || /^\[27;2;13~$/.test(input) || input === '\x1b\r'
|
|
983
952
|
}
|
|
984
953
|
|
|
985
954
|
function findPasteMarker(input: string, code: '200' | '201', from = 0): number {
|
|
@@ -1136,50 +1105,17 @@ function clickableUrl(url: string, maxLen?: number): string {
|
|
|
1136
1105
|
// displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
|
|
1137
1106
|
// here to size the AIMUX status block so the web URL truncates exactly.
|
|
1138
1107
|
|
|
1139
|
-
function
|
|
1140
|
-
const safeWidth = Math.max(1, width)
|
|
1141
|
-
return text.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(displayWidth(line) / safeWidth)), 0)
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
function entryRows(entry: AnyEntry, columns: number): number {
|
|
1145
|
-
const width = Math.max(8, columns - 4)
|
|
1146
|
-
return Math.max(1, viewsForEntry(entry).reduce((sum, view) => {
|
|
1147
|
-
switch (view.kind) {
|
|
1148
|
-
case 'skip': return sum
|
|
1149
|
-
case 'user': return sum + 1 + wrappedRows(view.text, width - 2)
|
|
1150
|
-
case 'assistant': {
|
|
1151
|
-
const rows = renderMarkdownLines(view.text).reduce((total, line) => {
|
|
1152
|
-
return total + (line.code ? 1 : wrappedRows(line.text || ' ', width - 2))
|
|
1153
|
-
}, 0)
|
|
1154
|
-
return sum + 1 + rows
|
|
1155
|
-
}
|
|
1156
|
-
case 'tool_call': return sum + 2 + (view.result ? 1 : 0)
|
|
1157
|
-
case 'tool_result': return sum + headTailLines(view.text, width - 4, 5).length
|
|
1158
|
-
case 'code_edit':
|
|
1159
|
-
return sum + 2 + wrappedRows(view.filePath || '(未指定文件)', width - 4)
|
|
1160
|
-
+ wrappedRows(view.oldString, width - 4) + wrappedRows(view.newString, width - 4)
|
|
1161
|
-
case 'write_file':
|
|
1162
|
-
return sum + 2 + wrappedRows(view.filePath || '(未指定文件)', width - 4)
|
|
1163
|
-
+ wrappedRows(view.content, width - 4)
|
|
1164
|
-
case 'reasoning': return sum + 1 + clampLines(view.text, width - 4, 2).length
|
|
1165
|
-
case 'system': return sum + 1
|
|
1166
|
-
case 'error': return sum + 1 + wrappedRows(view.text, width - 2)
|
|
1167
|
-
default: return sum
|
|
1168
|
-
}
|
|
1169
|
-
}, 0))
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
|
|
1108
|
+
export function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
|
|
1173
1109
|
entries: AnyEntry[]
|
|
1174
1110
|
/** Tail rows of the next older entry, used to fill spare space above the viewport. */
|
|
1175
|
-
|
|
1111
|
+
peekRows: ScreenRow[]
|
|
1176
1112
|
hiddenOlder: number
|
|
1177
1113
|
hiddenRecent: number
|
|
1178
1114
|
startIndex: number
|
|
1179
1115
|
} {
|
|
1180
1116
|
const tail = Math.max(0, entries.length - scrollBack)
|
|
1181
1117
|
const available = tail === 0 ? [] : entries.slice(0, tail)
|
|
1182
|
-
const renderedRows = available.map((entry) =>
|
|
1118
|
+
const renderedRows = available.map((entry) => entryScreenRows(viewsForEntry(entry), columns))
|
|
1183
1119
|
const fit = (budget: number) => {
|
|
1184
1120
|
let rows = 0
|
|
1185
1121
|
let first = available.length
|
|
@@ -1195,7 +1131,7 @@ function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number,
|
|
|
1195
1131
|
const base = fit(rowBudget)
|
|
1196
1132
|
let fitted = base
|
|
1197
1133
|
let first = fitted.first
|
|
1198
|
-
let
|
|
1134
|
+
let peekRows: ScreenRow[] = []
|
|
1199
1135
|
// When older history exists, guarantee at least one row for the tail of the
|
|
1200
1136
|
// next older message. If complete entries exactly consume the budget, refit
|
|
1201
1137
|
// them with one fewer row; only the oldest complete entry can drop out, while
|
|
@@ -1209,17 +1145,17 @@ function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number,
|
|
|
1209
1145
|
first = reduced.first
|
|
1210
1146
|
}
|
|
1211
1147
|
}
|
|
1212
|
-
const
|
|
1213
|
-
while (
|
|
1214
|
-
while (
|
|
1148
|
+
const olderRows = renderedRows[first - 1].slice()
|
|
1149
|
+
while (olderRows.length > 0 && !olderRows[0].plain.trim()) olderRows.shift()
|
|
1150
|
+
while (olderRows.length > 0 && !olderRows[olderRows.length - 1].plain.trim()) olderRows.pop()
|
|
1215
1151
|
const spare = rowBudget - fitted.rows
|
|
1216
|
-
if (spare > 0 &&
|
|
1217
|
-
|
|
1152
|
+
if (spare > 0 && olderRows.length > 0) {
|
|
1153
|
+
peekRows = olderRows.slice(-spare)
|
|
1218
1154
|
}
|
|
1219
1155
|
}
|
|
1220
1156
|
return {
|
|
1221
1157
|
entries: available.slice(first),
|
|
1222
|
-
|
|
1158
|
+
peekRows,
|
|
1223
1159
|
hiddenOlder: first,
|
|
1224
1160
|
hiddenRecent: entries.length - tail,
|
|
1225
1161
|
startIndex: first,
|
|
@@ -322,6 +322,7 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
322
322
|
{ label: '➕ 创建新项目', value: '__create__' },
|
|
323
323
|
...projects.map(p => ({ label: p.name, value: p.id, desc: p.description })),
|
|
324
324
|
]}
|
|
325
|
+
initialActive={projects.length > 0 ? 1 : 0}
|
|
325
326
|
onSelect={v => v === '__create__' ? setCreateMode('project') : pickProject(projects!.find(p => p.id === v)!)}
|
|
326
327
|
/>}
|
|
327
328
|
</Box>
|
|
@@ -345,6 +346,7 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
345
346
|
{ label: '➕ 创建新任务', value: '__create__' },
|
|
346
347
|
...issues.map(i => ({ label: i.title, value: i.id, desc: i.description })),
|
|
347
348
|
]}
|
|
349
|
+
initialActive={1}
|
|
348
350
|
onSelect={v => v === '__create__' ? setCreateMode('issue') : pickIssue(issues!.find(i => i.id === v)!)} />}
|
|
349
351
|
</Box>
|
|
350
352
|
<Text color="gray">↑↓ 选择 · 回车确认 · Esc 取消</Text>
|
|
@@ -3,9 +3,123 @@
|
|
|
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, useStdin } from 'ink'
|
|
6
|
+
import { Box, Text, useInput, useStdout, useStdin, type Key } from 'ink'
|
|
7
7
|
import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
|
|
8
8
|
|
|
9
|
+
/** Return false when a mounted listener deliberately did not consume the input. */
|
|
10
|
+
type InputHandler = (input: string, key: Key) => void | false
|
|
11
|
+
|
|
12
|
+
type StableInputOptions = {
|
|
13
|
+
isActive?: boolean
|
|
14
|
+
/** Passive listeners (App raw-mode keepalive, Chat paging) must not claim or receive replayed input. */
|
|
15
|
+
interactive?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ReplayInput = { input: string; key: Key; signature: string; claimed: boolean }
|
|
19
|
+
|
|
20
|
+
const pendingRootInputs: ReplayInput[] = []
|
|
21
|
+
const replayQueue: ReplayInput[] = []
|
|
22
|
+
const interactiveHandlers = new Set<InputHandler>()
|
|
23
|
+
const earlyClaimCredits = new Map<string, number>()
|
|
24
|
+
let earlyClaimCleanupScheduled = false
|
|
25
|
+
let replayingInput = false
|
|
26
|
+
|
|
27
|
+
function inputSignature(input: string, key: Key): string {
|
|
28
|
+
const flags = Object.keys(key)
|
|
29
|
+
.filter(name => Boolean((key as unknown as Record<string, unknown>)[name]))
|
|
30
|
+
.sort()
|
|
31
|
+
.join(',')
|
|
32
|
+
return `${input}\u0000${flags}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function markRootInputClaimed(input: string, key: Key): void {
|
|
36
|
+
const signature = inputSignature(input, key)
|
|
37
|
+
const pending = pendingRootInputs.find(event => !event.claimed && event.signature === signature)
|
|
38
|
+
if (pending) {
|
|
39
|
+
pending.claimed = true
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// React/Ink may register a child's listener before the App listener. Keep a
|
|
44
|
+
// one-microtask credit so the root callback later in the same emitter pass
|
|
45
|
+
// recognizes that this exact input was already handled.
|
|
46
|
+
earlyClaimCredits.set(signature, (earlyClaimCredits.get(signature) ?? 0) + 1)
|
|
47
|
+
if (!earlyClaimCleanupScheduled) {
|
|
48
|
+
earlyClaimCleanupScheduled = true
|
|
49
|
+
queueMicrotask(() => {
|
|
50
|
+
earlyClaimCredits.clear()
|
|
51
|
+
earlyClaimCleanupScheduled = false
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function deliverOrQueue(event: ReplayInput): void {
|
|
57
|
+
for (const handler of Array.from(interactiveHandlers).reverse()) {
|
|
58
|
+
replayingInput = true
|
|
59
|
+
try {
|
|
60
|
+
if (handler(event.input, event.key) !== false) return
|
|
61
|
+
} finally {
|
|
62
|
+
replayingInput = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
replayQueue.push(event)
|
|
66
|
+
if (replayQueue.length > 8) replayQueue.shift()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* App-level input safety net. It stays mounted across async route changes and
|
|
71
|
+
* only buffers a key when no interactive Ink listener claimed that emitter
|
|
72
|
+
* pass. The next Select/TextInput/Composer receives the key after it mounts.
|
|
73
|
+
*/
|
|
74
|
+
export function bufferUnclaimedInput(input: string, key: Key): void {
|
|
75
|
+
if (isMouseInput(input)) return
|
|
76
|
+
const signature = inputSignature(input, key)
|
|
77
|
+
const credits = earlyClaimCredits.get(signature) ?? 0
|
|
78
|
+
if (credits > 0) {
|
|
79
|
+
if (credits === 1) earlyClaimCredits.delete(signature)
|
|
80
|
+
else earlyClaimCredits.set(signature, credits - 1)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const event: ReplayInput = { input, key: { ...key }, signature, claimed: false }
|
|
85
|
+
pendingRootInputs.push(event)
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
const index = pendingRootInputs.indexOf(event)
|
|
88
|
+
if (index >= 0) pendingRootInputs.splice(index, 1)
|
|
89
|
+
if (!event.claimed) deliverOrQueue(event)
|
|
90
|
+
}, 0)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Keep one Ink listener while a component rerenders; read the latest handler through a ref. */
|
|
94
|
+
export function useStableInput(handler: InputHandler, options?: StableInputOptions): void {
|
|
95
|
+
const handlerRef = useRef(handler)
|
|
96
|
+
handlerRef.current = handler
|
|
97
|
+
const stableRef = useRef<InputHandler | null>(null)
|
|
98
|
+
const interactive = options?.interactive !== false
|
|
99
|
+
if (!stableRef.current) {
|
|
100
|
+
stableRef.current = (input, key) => {
|
|
101
|
+
const handled = handlerRef.current(input, key)
|
|
102
|
+
if (interactive && !replayingInput && handled !== false) markRootInputClaimed(input, key)
|
|
103
|
+
return handled
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
useInput(stableRef.current, { isActive: options?.isActive })
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!interactive || options?.isActive === false || !stableRef.current) return
|
|
110
|
+
const stable = stableRef.current
|
|
111
|
+
interactiveHandlers.add(stable)
|
|
112
|
+
const queued = replayQueue.splice(0)
|
|
113
|
+
replayingInput = true
|
|
114
|
+
try {
|
|
115
|
+
for (const event of queued) stable(event.input, event.key)
|
|
116
|
+
} finally {
|
|
117
|
+
replayingInput = false
|
|
118
|
+
}
|
|
119
|
+
return () => { interactiveHandlers.delete(stable) }
|
|
120
|
+
}, [interactive, options?.isActive])
|
|
121
|
+
}
|
|
122
|
+
|
|
9
123
|
/** Windows Terminal/ConPTY may expose Esc as a named key, a raw byte, or Ctrl+[. */
|
|
10
124
|
export function isEscapeKeypress(input: string, key: { escape?: boolean; ctrl?: boolean }): boolean {
|
|
11
125
|
return key.escape === true || input === '\x1b' || (key.ctrl === true && input === '[')
|
|
@@ -185,7 +299,7 @@ export function TextInput(props: TextInputProps) {
|
|
|
185
299
|
edit(text, nextCursor)
|
|
186
300
|
})
|
|
187
301
|
|
|
188
|
-
|
|
302
|
+
useStableInput((input, key) => {
|
|
189
303
|
if (isMouseInput(input)) return
|
|
190
304
|
if (key.return) { props.onSubmit?.(); return }
|
|
191
305
|
if (key.upArrow) { props.onArrowUp?.(); return }
|
|
@@ -298,24 +412,28 @@ export interface SelectProps {
|
|
|
298
412
|
focused?: boolean
|
|
299
413
|
title?: string
|
|
300
414
|
maxVisible?: number // cap rendered rows so long lists never overflow the terminal
|
|
415
|
+
initialActive?: number // initial keyboard focus; useful when a create action occupies row 0
|
|
301
416
|
}
|
|
302
417
|
|
|
303
418
|
export function Select(props: SelectProps) {
|
|
304
419
|
const mode = props.mode ?? 'single'
|
|
305
|
-
const [active, setActive] = useState(0)
|
|
420
|
+
const [active, setActive] = useState(() => Math.max(0, props.initialActive ?? 0))
|
|
306
421
|
const items = props.items
|
|
307
422
|
const selectedSet = new Set<string>(mode === 'multi' ? (props.selected as string[]) ?? [] : [])
|
|
308
423
|
const { stdout } = useStdout()
|
|
309
424
|
|
|
310
425
|
useEffect(() => { setActive(a => Math.min(a, Math.max(0, items.length - 1))) }, [items.length])
|
|
311
426
|
|
|
312
|
-
|
|
427
|
+
useStableInput((input, key) => {
|
|
313
428
|
if (!items.length) return
|
|
314
429
|
if (isMouseInput(input)) return
|
|
315
430
|
if (key.upArrow) { setActive(a => (a - 1 + items.length) % items.length); return }
|
|
316
431
|
if (key.downArrow) { setActive(a => (a + 1) % items.length); return }
|
|
317
432
|
if (mode === 'single') {
|
|
318
|
-
if (key.return) {
|
|
433
|
+
if (key.return) {
|
|
434
|
+
props.onSelect?.(items[active].value)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
319
437
|
} else {
|
|
320
438
|
if (key.return) { props.onConfirm?.(Array.from(selectedSet)); return }
|
|
321
439
|
if (input === ' ') { props.onToggle?.(items[active].value); return }
|