@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.
@@ -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 { 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'
@@ -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.peekLines.length > 0
336
+ {fitted.peekRows.length > 0
333
337
  ? <Box width="100%" flexShrink={0} flexDirection="column">
334
- {fitted.peekLines.map((line, index) => (
335
- <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
+ />
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
- function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
449
- const views = viewsForEntry(entry)
450
- return (
451
- <Box flexDirection="column">
452
- {views.map((view, index) => <ViewLine key={index} view={view} columns={columns} />)}
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: Map<number, { start: number; end: number }>
462
+ sel?: Map<number, { start: number; end: number }>
465
463
  }) {
466
- const lines = entryScreenLines(viewsForEntry(entry), columns)
464
+ const rows = entryScreenRows(viewsForEntry(entry), columns)
467
465
  return (
468
466
  <Box flexDirection="column">
469
- {lines.map((row, index) => {
470
- const range = sel.get(index)
471
- if (range && range.start < range.end) {
472
- return (
473
- <Text key={index} wrap="truncate-end">
474
- <Text>{row.slice(0, range.start)}</Text>
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 ViewLine({ view, columns }: { view: EntryView; columns: number }) {
487
- const width = Math.max(8, columns - 4)
488
- switch (view.kind) {
489
- case 'skip':
490
- return null
491
- case 'user':
492
- return <UserLine text={view.text} />
493
- case 'assistant': {
494
- const lines = renderMarkdownLines(view.text)
495
- return (
496
- <Box marginTop={1} flexDirection="column">
497
- {lines.map((line, index) => (
498
- <Text key={index} wrap={line.code ? 'truncate-end' : 'wrap'}>
499
- {index === 0 ? '' : ' '}{line.text || ' '}
500
- </Text>
501
- ))}
502
- </Box>
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
- case 'tool_call': {
506
- // compact (≤2 行): 命令行 + 可选结果行 (已与 tool_result 合并).
507
- const head = clampLines(`${toolLabel(view.toolName)} ${view.summary}`.trim(), width - 2, 1)[0]
508
- return (
509
- <Box marginTop={1} flexDirection="column">
510
- <Text color="cyan">• {head}</Text>
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
- case 'tool_result': {
520
- // codex 式 head+ellipsis+tail (output_max_lines=5): tool 结果保留头尾,
521
- // 中间省略行数; DIM 样式 + └/缩进前缀 (对齐 codex exec_cell/render.rs).
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
- case 'code_edit':
532
- return <CodeEditView view={view} />
533
- case 'write_file':
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 /^\[(?:13|27);2(?:u|~)$/.test(input) || input === '\x1b\r'
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 wrappedRows(text: string, width: number): number {
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
- peekLines: string[]
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) => entryScreenLines(viewsForEntry(entry), columns))
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 peekLines: string[] = []
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 olderLines = renderedRows[first - 1].slice()
1239
- while (olderLines.length > 0 && !olderLines[0].trim()) olderLines.shift()
1240
- 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()
1241
1151
  const spare = rowBudget - fitted.rows
1242
- if (spare > 0 && olderLines.length > 0) {
1243
- peekLines = olderLines.slice(-spare)
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
- peekLines,
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
- type InputHandler = (input: string, key: Key) => void
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?: { isActive?: boolean }): void {
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
- if (!stableRef.current) stableRef.current = (input, key) => handlerRef.current(input, key)
17
- useInput(stableRef.current, options)
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) { props.onSelect?.(items[active].value); 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 }