@mobius-os/mobius 0.3.27 → 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 +2 -2
- package/src/components/Chat.tsx +93 -183
- package/src/components/ConfigFlow.tsx +2 -0
- package/src/components/primitives.tsx +113 -6
- 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
|
@@ -11,13 +11,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
11
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'
|
|
@@ -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.
|
|
@@ -173,7 +177,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
173
177
|
const step = Math.max(1, fitted.entries.length)
|
|
174
178
|
if (key.pageUp) setScrollBack(value => Math.min(dedupedEntries.length, value + step))
|
|
175
179
|
else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
|
|
176
|
-
})
|
|
180
|
+
}, { interactive: false })
|
|
177
181
|
|
|
178
182
|
const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
|
|
179
183
|
? fitted.hiddenOlder > 0
|
|
@@ -329,19 +333,21 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
329
333
|
: null}
|
|
330
334
|
|
|
331
335
|
<Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome || fitted.hiddenOlder > 0 ? 'flex-start' : 'flex-end'} overflowY="hidden">
|
|
332
|
-
{fitted.
|
|
336
|
+
{fitted.peekRows.length > 0
|
|
333
337
|
? <Box width="100%" flexShrink={0} flexDirection="column">
|
|
334
|
-
{fitted.
|
|
335
|
-
<
|
|
338
|
+
{fitted.peekRows.map((row, index) => (
|
|
339
|
+
<ScreenText
|
|
340
|
+
key={`peek-${index}`}
|
|
341
|
+
row={row}
|
|
342
|
+
text={`${index === 0 ? ' ⋯ ' : ' '}${row.styled}`}
|
|
343
|
+
/>
|
|
336
344
|
))}
|
|
337
345
|
</Box>
|
|
338
346
|
: null}
|
|
339
347
|
{fitted.entries.map((entry, index) => {
|
|
340
348
|
const entrySel = selMap?.get(index)
|
|
341
349
|
const key = entry.__id ?? `entry-${fitted.startIndex + index}`
|
|
342
|
-
return entrySel
|
|
343
|
-
? <EntryScreenWithSelection key={key} entry={entry} columns={terminal.columns} sel={entrySel} />
|
|
344
|
-
: <EntryAccum key={key} entry={entry} columns={terminal.columns} />
|
|
350
|
+
return <EntryScreen key={key} entry={entry} columns={terminal.columns} sel={entrySel} />
|
|
345
351
|
})}
|
|
346
352
|
{chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
|
|
347
353
|
{fitted.hiddenRecent > 0
|
|
@@ -367,6 +373,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
367
373
|
typing={chat.typing}
|
|
368
374
|
commands={SLASH_COMMANDS}
|
|
369
375
|
onHeightChange={setComposerRows}
|
|
376
|
+
inputActiveRef={chatInputActiveRef}
|
|
370
377
|
/>
|
|
371
378
|
<StatusArea
|
|
372
379
|
ready={ready}
|
|
@@ -445,149 +452,83 @@ function CompactHeader({ ready, sessionId, columns }: { ready: ReadyState; sessi
|
|
|
445
452
|
)
|
|
446
453
|
}
|
|
447
454
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
</Box>
|
|
454
|
-
)
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// While a drag-selection is active, the affected entries are re-rendered from the
|
|
458
|
-
// screen-text model (plain rows, no ANSI) so the selected char range can be
|
|
459
|
-
// painted with a background — the same rows the model produces, keeping the
|
|
460
|
-
// layout stable. Rows outside the selection keep their original text.
|
|
461
|
-
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 }: {
|
|
462
460
|
entry: AnyEntry
|
|
463
461
|
columns: number
|
|
464
|
-
sel
|
|
462
|
+
sel?: Map<number, { start: number; end: number }>
|
|
465
463
|
}) {
|
|
466
|
-
const
|
|
464
|
+
const rows = entryScreenRows(viewsForEntry(entry), columns)
|
|
467
465
|
return (
|
|
468
466
|
<Box flexDirection="column">
|
|
469
|
-
{
|
|
470
|
-
const range = sel
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
<Text backgroundColor="cyan" color="black">{row.slice(range.start, range.end)}</Text>
|
|
476
|
-
<Text>{row.slice(range.end)}</Text>
|
|
477
|
-
</Text>
|
|
478
|
-
)
|
|
479
|
-
}
|
|
480
|
-
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 || ' '} />
|
|
481
473
|
})}
|
|
482
474
|
</Box>
|
|
483
475
|
)
|
|
484
476
|
}
|
|
485
477
|
|
|
486
|
-
function
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
+
}
|
|
504
512
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
{view.result ? (
|
|
512
|
-
<Text dimColor color={view.result.isError ? 'red' : undefined}>
|
|
513
|
-
{' └ '}{clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}
|
|
514
|
-
</Text>
|
|
515
|
-
) : null}
|
|
516
|
-
</Box>
|
|
517
|
-
)
|
|
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
|
|
518
519
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const lines = headTailLines(view.text, width - 4, 5)
|
|
523
|
-
return (
|
|
524
|
-
<Box flexDirection="column">
|
|
525
|
-
{lines.map((l, i) => (
|
|
526
|
-
<Text key={i} dimColor color={view.isError ? 'red' : undefined}>{i === 0 ? ' └ ' : ' '}{l}</Text>
|
|
527
|
-
))}
|
|
528
|
-
</Box>
|
|
529
|
-
)
|
|
520
|
+
if (highlighted && visible >= end) {
|
|
521
|
+
out += SELECTION_BG_END
|
|
522
|
+
highlighted = false
|
|
530
523
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
return <WriteFileView view={view} />
|
|
535
|
-
case 'reasoning': {
|
|
536
|
-
const lines = clampLines(view.text, width - 4, 2)
|
|
537
|
-
return (
|
|
538
|
-
<Box marginTop={1} flexDirection="column">
|
|
539
|
-
{lines.map((line, i) => (
|
|
540
|
-
<Text key={i} dimColor color="magenta">{i === 0 ? ' ◇ ' : ' '}{line}</Text>
|
|
541
|
-
))}
|
|
542
|
-
</Box>
|
|
543
|
-
)
|
|
544
|
-
}
|
|
545
|
-
case 'system':
|
|
546
|
-
return <Text dimColor color="yellow"> {clampLines(view.text, width - 2, 2)[0]}</Text>
|
|
547
|
-
case 'error':
|
|
548
|
-
return (
|
|
549
|
-
<Box marginTop={1} flexDirection="column">
|
|
550
|
-
{view.text.split('\n').map((line, i) => (
|
|
551
|
-
<Text key={i} color="red">{i === 0 ? '⚠ ' : ' '}{line}</Text>
|
|
552
|
-
))}
|
|
553
|
-
</Box>
|
|
554
|
-
)
|
|
555
|
-
default:
|
|
556
|
-
return null
|
|
524
|
+
out += char
|
|
525
|
+
raw += char.length
|
|
526
|
+
visible = nextVisible
|
|
557
527
|
}
|
|
528
|
+
if (highlighted) out += SELECTION_BG_END
|
|
529
|
+
return out
|
|
558
530
|
}
|
|
559
531
|
|
|
560
|
-
// 代码修改 (Edit/StrReplace/apply_patch) — full: 完整展示 old(−)/new(+) 改动原文.
|
|
561
|
-
function CodeEditView({ view }: { view: { filePath: string; oldString: string; newString: string } }) {
|
|
562
|
-
return (
|
|
563
|
-
<Box marginTop={1} flexDirection="column">
|
|
564
|
-
<Text color="magenta">✎ 编辑 {view.filePath || '(未指定文件)'}</Text>
|
|
565
|
-
{view.oldString ? view.oldString.split('\n').map((line, i) => (
|
|
566
|
-
<Text key={`o${i}`} color="red">{' − '}{line}</Text>
|
|
567
|
-
)) : null}
|
|
568
|
-
{view.newString ? view.newString.split('\n').map((line, i) => (
|
|
569
|
-
<Text key={`n${i}`} color="green">{' + '}{line}</Text>
|
|
570
|
-
)) : null}
|
|
571
|
-
</Box>
|
|
572
|
-
)
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// 文件写入 (Write/create_file) — full: 完整展示写入内容原文.
|
|
576
|
-
function WriteFileView({ view }: { view: { filePath: string; content: string } }) {
|
|
577
|
-
return (
|
|
578
|
-
<Box marginTop={1} flexDirection="column">
|
|
579
|
-
<Text color="magenta">✎ 写入 {view.filePath || '(未指定文件)'}</Text>
|
|
580
|
-
{view.content.split('\n').map((line, i) => (
|
|
581
|
-
<Text key={i} color="green">{' + '}{line}</Text>
|
|
582
|
-
))}
|
|
583
|
-
</Box>
|
|
584
|
-
)
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// clampLines / headTailLines / displayWidth live in src/lib/screen-text.ts
|
|
588
|
-
// (mirrored, exported) and are imported above; they must match ViewLine exactly
|
|
589
|
-
// so the drag-selection text model aligns with the rendered rows.
|
|
590
|
-
|
|
591
532
|
function UserLine({ text }: { text: string }) {
|
|
592
533
|
const lines = text.split('\n')
|
|
593
534
|
if (lines[0] !== undefined) lines[0] = `› ${lines[0]}`
|
|
@@ -653,9 +594,10 @@ interface ComposerProps {
|
|
|
653
594
|
typing: boolean
|
|
654
595
|
commands: { cmd: string; desc: string }[]
|
|
655
596
|
onHeightChange?: (rows: number) => void
|
|
597
|
+
inputActiveRef?: React.RefObject<boolean>
|
|
656
598
|
}
|
|
657
599
|
|
|
658
|
-
export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange }: ComposerProps) {
|
|
600
|
+
export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange, inputActiveRef }: ComposerProps) {
|
|
659
601
|
const [value, setValue] = useState('')
|
|
660
602
|
const [cursor, setCursor] = useState(0)
|
|
661
603
|
const [popupIdx, setPopupIdx] = useState(0)
|
|
@@ -799,6 +741,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
799
741
|
useEffect(() => () => resetPasteBurst(), [])
|
|
800
742
|
|
|
801
743
|
useStableInput((input, key) => {
|
|
744
|
+
if (inputActiveRef?.current === false) return false
|
|
802
745
|
if (isMouseInput(input)) return // mouse events must never become typed text
|
|
803
746
|
const now = Date.now()
|
|
804
747
|
const escape = isEscapeKeypress(input, key)
|
|
@@ -1005,7 +948,7 @@ function normalizeComposerPaste(text: string): string {
|
|
|
1005
948
|
}
|
|
1006
949
|
|
|
1007
950
|
function isEnhancedNewlineInput(input: string): boolean {
|
|
1008
|
-
return /^\[(
|
|
951
|
+
return /^\[13;2u$/.test(input) || /^\[27;2;13~$/.test(input) || input === '\x1b\r'
|
|
1009
952
|
}
|
|
1010
953
|
|
|
1011
954
|
function findPasteMarker(input: string, code: '200' | '201', from = 0): number {
|
|
@@ -1162,50 +1105,17 @@ function clickableUrl(url: string, maxLen?: number): string {
|
|
|
1162
1105
|
// displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
|
|
1163
1106
|
// here to size the AIMUX status block so the web URL truncates exactly.
|
|
1164
1107
|
|
|
1165
|
-
function
|
|
1166
|
-
const safeWidth = Math.max(1, width)
|
|
1167
|
-
return text.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(displayWidth(line) / safeWidth)), 0)
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
function entryRows(entry: AnyEntry, columns: number): number {
|
|
1171
|
-
const width = Math.max(8, columns - 4)
|
|
1172
|
-
return Math.max(1, viewsForEntry(entry).reduce((sum, view) => {
|
|
1173
|
-
switch (view.kind) {
|
|
1174
|
-
case 'skip': return sum
|
|
1175
|
-
case 'user': return sum + 1 + wrappedRows(view.text, width - 2)
|
|
1176
|
-
case 'assistant': {
|
|
1177
|
-
const rows = renderMarkdownLines(view.text).reduce((total, line) => {
|
|
1178
|
-
return total + (line.code ? 1 : wrappedRows(line.text || ' ', width - 2))
|
|
1179
|
-
}, 0)
|
|
1180
|
-
return sum + 1 + rows
|
|
1181
|
-
}
|
|
1182
|
-
case 'tool_call': return sum + 2 + (view.result ? 1 : 0)
|
|
1183
|
-
case 'tool_result': return sum + headTailLines(view.text, width - 4, 5).length
|
|
1184
|
-
case 'code_edit':
|
|
1185
|
-
return sum + 2 + wrappedRows(view.filePath || '(未指定文件)', width - 4)
|
|
1186
|
-
+ wrappedRows(view.oldString, width - 4) + wrappedRows(view.newString, width - 4)
|
|
1187
|
-
case 'write_file':
|
|
1188
|
-
return sum + 2 + wrappedRows(view.filePath || '(未指定文件)', width - 4)
|
|
1189
|
-
+ wrappedRows(view.content, width - 4)
|
|
1190
|
-
case 'reasoning': return sum + 1 + clampLines(view.text, width - 4, 2).length
|
|
1191
|
-
case 'system': return sum + 1
|
|
1192
|
-
case 'error': return sum + 1 + wrappedRows(view.text, width - 2)
|
|
1193
|
-
default: return sum
|
|
1194
|
-
}
|
|
1195
|
-
}, 0))
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
|
|
1108
|
+
export function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
|
|
1199
1109
|
entries: AnyEntry[]
|
|
1200
1110
|
/** Tail rows of the next older entry, used to fill spare space above the viewport. */
|
|
1201
|
-
|
|
1111
|
+
peekRows: ScreenRow[]
|
|
1202
1112
|
hiddenOlder: number
|
|
1203
1113
|
hiddenRecent: number
|
|
1204
1114
|
startIndex: number
|
|
1205
1115
|
} {
|
|
1206
1116
|
const tail = Math.max(0, entries.length - scrollBack)
|
|
1207
1117
|
const available = tail === 0 ? [] : entries.slice(0, tail)
|
|
1208
|
-
const renderedRows = available.map((entry) =>
|
|
1118
|
+
const renderedRows = available.map((entry) => entryScreenRows(viewsForEntry(entry), columns))
|
|
1209
1119
|
const fit = (budget: number) => {
|
|
1210
1120
|
let rows = 0
|
|
1211
1121
|
let first = available.length
|
|
@@ -1221,7 +1131,7 @@ function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number,
|
|
|
1221
1131
|
const base = fit(rowBudget)
|
|
1222
1132
|
let fitted = base
|
|
1223
1133
|
let first = fitted.first
|
|
1224
|
-
let
|
|
1134
|
+
let peekRows: ScreenRow[] = []
|
|
1225
1135
|
// When older history exists, guarantee at least one row for the tail of the
|
|
1226
1136
|
// next older message. If complete entries exactly consume the budget, refit
|
|
1227
1137
|
// them with one fewer row; only the oldest complete entry can drop out, while
|
|
@@ -1235,17 +1145,17 @@ function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number,
|
|
|
1235
1145
|
first = reduced.first
|
|
1236
1146
|
}
|
|
1237
1147
|
}
|
|
1238
|
-
const
|
|
1239
|
-
while (
|
|
1240
|
-
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()
|
|
1241
1151
|
const spare = rowBudget - fitted.rows
|
|
1242
|
-
if (spare > 0 &&
|
|
1243
|
-
|
|
1152
|
+
if (spare > 0 && olderRows.length > 0) {
|
|
1153
|
+
peekRows = olderRows.slice(-spare)
|
|
1244
1154
|
}
|
|
1245
1155
|
}
|
|
1246
1156
|
return {
|
|
1247
1157
|
entries: available.slice(first),
|
|
1248
|
-
|
|
1158
|
+
peekRows,
|
|
1249
1159
|
hiddenOlder: first,
|
|
1250
1160
|
hiddenRecent: entries.length - tail,
|
|
1251
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>
|
|
@@ -6,15 +6,118 @@ import React, { useEffect, useRef, useState } from 'react'
|
|
|
6
6
|
import { Box, Text, useInput, useStdout, useStdin, type Key } from 'ink'
|
|
7
7
|
import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
|
|
8
8
|
|
|
9
|
-
|
|
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
|
+
}
|
|
10
92
|
|
|
11
93
|
/** Keep one Ink listener while a component rerenders; read the latest handler through a ref. */
|
|
12
|
-
export function useStableInput(handler: InputHandler, options?:
|
|
94
|
+
export function useStableInput(handler: InputHandler, options?: StableInputOptions): void {
|
|
13
95
|
const handlerRef = useRef(handler)
|
|
14
96
|
handlerRef.current = handler
|
|
15
97
|
const stableRef = useRef<InputHandler | null>(null)
|
|
16
|
-
|
|
17
|
-
|
|
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])
|
|
18
121
|
}
|
|
19
122
|
|
|
20
123
|
/** Windows Terminal/ConPTY may expose Esc as a named key, a raw byte, or Ctrl+[. */
|
|
@@ -309,11 +412,12 @@ export interface SelectProps {
|
|
|
309
412
|
focused?: boolean
|
|
310
413
|
title?: string
|
|
311
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
|
|
312
416
|
}
|
|
313
417
|
|
|
314
418
|
export function Select(props: SelectProps) {
|
|
315
419
|
const mode = props.mode ?? 'single'
|
|
316
|
-
const [active, setActive] = useState(0)
|
|
420
|
+
const [active, setActive] = useState(() => Math.max(0, props.initialActive ?? 0))
|
|
317
421
|
const items = props.items
|
|
318
422
|
const selectedSet = new Set<string>(mode === 'multi' ? (props.selected as string[]) ?? [] : [])
|
|
319
423
|
const { stdout } = useStdout()
|
|
@@ -326,7 +430,10 @@ export function Select(props: SelectProps) {
|
|
|
326
430
|
if (key.upArrow) { setActive(a => (a - 1 + items.length) % items.length); return }
|
|
327
431
|
if (key.downArrow) { setActive(a => (a + 1) % items.length); return }
|
|
328
432
|
if (mode === 'single') {
|
|
329
|
-
if (key.return) {
|
|
433
|
+
if (key.return) {
|
|
434
|
+
props.onSelect?.(items[active].value)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
330
437
|
} else {
|
|
331
438
|
if (key.return) { props.onConfirm?.(Array.from(selectedSet)); return }
|
|
332
439
|
if (input === ' ') { props.onToggle?.(items[active].value); return }
|