@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.
@@ -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, useInput, useStdout } from 'ink'
11
+ import { Box, Text, useStdout } from 'ink'
12
12
  import { useChat } from '../hooks/useChat.js'
13
13
  import { MobiusClient } from '../api.js'
14
- import { renderMarkdownLines } from '../markdown.js'
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
- clampLines, headTailLines, displayWidth, compareSel, entryScreenLines,
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
- useInput((_input, key) => {
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 step = 3
218
- setScrollBack(value => Math.min(dedupedEntries.length, Math.max(0, value + delta * step)))
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.peekLines.length > 0
336
+ {fitted.peekRows.length > 0
307
337
  ? <Box width="100%" flexShrink={0} flexDirection="column">
308
- {fitted.peekLines.map((line, index) => (
309
- <Text key={`peek-${index}`} dimColor wrap="truncate-end">{index === 0 ? ' ⋯ ' : ' '}{line}</Text>
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
- function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
423
- const views = viewsForEntry(entry)
424
- return (
425
- <Box flexDirection="column">
426
- {views.map((view, index) => <ViewLine key={index} view={view} columns={columns} />)}
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: Map<number, { start: number; end: number }>
462
+ sel?: Map<number, { start: number; end: number }>
439
463
  }) {
440
- const lines = entryScreenLines(viewsForEntry(entry), columns)
464
+ const rows = entryScreenRows(viewsForEntry(entry), columns)
441
465
  return (
442
466
  <Box flexDirection="column">
443
- {lines.map((row, index) => {
444
- const range = sel.get(index)
445
- if (range && range.start < range.end) {
446
- return (
447
- <Text key={index} wrap="truncate-end">
448
- <Text>{row.slice(0, range.start)}</Text>
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 ViewLine({ view, columns }: { view: EntryView; columns: number }) {
461
- const width = Math.max(8, columns - 4)
462
- switch (view.kind) {
463
- case 'skip':
464
- return null
465
- case 'user':
466
- return <UserLine text={view.text} />
467
- case 'assistant': {
468
- const lines = renderMarkdownLines(view.text)
469
- return (
470
- <Box marginTop={1} flexDirection="column">
471
- {lines.map((line, index) => (
472
- <Text key={index} wrap={line.code ? 'truncate-end' : 'wrap'}>
473
- {index === 0 ? '' : ' '}{line.text || ' '}
474
- </Text>
475
- ))}
476
- </Box>
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
- case 'tool_call': {
480
- // compact (≤2 行): 命令行 + 可选结果行 (已与 tool_result 合并).
481
- const head = clampLines(`${toolLabel(view.toolName)} ${view.summary}`.trim(), width - 2, 1)[0]
482
- return (
483
- <Box marginTop={1} flexDirection="column">
484
- <Text color="cyan">• {head}</Text>
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
- case 'tool_result': {
494
- // codex 式 head+ellipsis+tail (output_max_lines=5): tool 结果保留头尾,
495
- // 中间省略行数; DIM 样式 + └/缩进前缀 (对齐 codex exec_cell/render.rs).
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
- case 'code_edit':
506
- return <CodeEditView view={view} />
507
- case 'write_file':
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
- useInput((input, key) => {
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 /^\[(?:13|27);2(?:u|~)$/.test(input) || input === '\x1b\r'
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 wrappedRows(text: string, width: number): number {
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
- peekLines: string[]
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) => entryScreenLines(viewsForEntry(entry), columns))
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 peekLines: string[] = []
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 olderLines = renderedRows[first - 1].slice()
1213
- while (olderLines.length > 0 && !olderLines[0].trim()) olderLines.shift()
1214
- while (olderLines.length > 0 && !olderLines[olderLines.length - 1].trim()) olderLines.pop()
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 && olderLines.length > 0) {
1217
- peekLines = olderLines.slice(-spare)
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
- peekLines,
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
- useInput((input, key) => {
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
- useInput((input, key) => {
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) { props.onSelect?.(items[active].value); 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 }