@mobius-os/mobius 0.3.27 → 0.3.34

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,14 +11,16 @@ 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,
18
- buildTranscriptModel, computeTranscriptGeometry, screenToSelPoint,
16
+ displayWidth, compareSel, entryScreenRows, screenToSelPoint,
19
17
  buildSelectionMap, buildSelectionText, osc52,
20
- type TranscriptModel, type TranscriptGeometry, type SelPoint,
18
+ type TranscriptModel, type TranscriptGeometry, type SelPoint, type ScreenRow,
21
19
  } from '../lib/screen-text.js'
20
+ import {
21
+ createRowAccess, moveAnchorByRows, sliceViewport, tailAnchor,
22
+ type RowAnchor,
23
+ } from '../lib/transcript-viewport.js'
22
24
  import type { ReadyState } from './PrepScreen.js'
23
25
  import type { AnyEntry } from '../types.js'
24
26
  import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
@@ -46,8 +48,7 @@ interface TerminalSize {
46
48
  isTty: boolean
47
49
  }
48
50
 
49
- import { createRequire } from 'node:module'
50
- const VERSION = createRequire(import.meta.url)('../../package.json').version
51
+ import { TUI_VERSION } from '../version.js'
51
52
  const DEFAULT_COMPOSER_ROWS = 5
52
53
  const STATUS_ROWS = 3
53
54
 
@@ -63,11 +64,18 @@ const SLASH_COMMANDS = [
63
64
  export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
64
65
  const chat = useChat({ client, ready, resumeSessionId })
65
66
  const [showHelp, setShowHelp] = useState(false)
66
- const [scrollBack, setScrollBack] = useState(0)
67
+ // null means "follow the tail". A concrete anchor identifies the exact row
68
+ // at the top of the viewport while the user browses history.
69
+ const [rowAnchor, setRowAnchor] = useState<RowAnchor | null>(null)
67
70
  const [composerRows, setComposerRows] = useState(DEFAULT_COMPOSER_ROWS)
68
71
  const [modelLabel, setModelLabel] = useState<string | null>(null)
69
72
  const [configOpen, setConfigOpen] = useState(false)
70
73
  const [reconfigOpen, setReconfigOpen] = useState(false)
74
+ // Ink may deliver one final event to Composer while an async config picker is
75
+ // replacing it. The shared ref lets that stale listener report "not handled"
76
+ // so App can replay the key after the new Select mounts.
77
+ const chatInputActiveRef = useRef(true)
78
+ chatInputActiveRef.current = !configOpen && !reconfigOpen
71
79
  // Ink's useInput keeps whatever handler was registered at subscription time;
72
80
  // reading mutable refs (updated every render) keeps the callback from acting
73
81
  // on a stale `configOpen`/sessionId closure after the config flow opens.
@@ -96,7 +104,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
96
104
  return
97
105
  }
98
106
  setShowHelp(false)
99
- setScrollBack(0)
107
+ setRowAnchor(null)
100
108
  void chat.send(t)
101
109
  }, [chat, runSlash])
102
110
 
@@ -112,39 +120,66 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
112
120
  // (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
113
121
  // 避免在累积视图里把同一条提问显示多次.
114
122
  const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
115
- // Cache: entry index rendered line count. Cleared when entries or columns change.
116
- const entryLines = useRef<Map<number, number>>(new Map())
117
- useEffect(() => { entryLines.current.clear() }, [dedupedEntries, terminal.columns])
118
- const getEntryLines = useCallback((i: number) => {
119
- const c = entryLines.current.get(i)
120
- if (c !== undefined) return c
121
- const n = entryScreenLines(viewsForEntry(dedupedEntries[i]), terminal.columns).length || 1
122
- entryLines.current.set(i, n)
123
- return n
124
- }, [dedupedEntries, terminal.columns])
123
+ const pendingEntry = useMemo<AnyEntry | null>(() => chat.pendingUser === null ? null : ({
124
+ type: 'user',
125
+ __id: '__pending-user__',
126
+ message: { role: 'user', content: chat.pendingUser },
127
+ }), [chat.pendingUser])
128
+ const transcriptEntries = useMemo(
129
+ () => pendingEntry ? [...dedupedEntries, pendingEntry] : dedupedEntries,
130
+ [dedupedEntries, pendingEntry],
131
+ )
132
+
133
+ // Markdown parsing and wrapping are paid once per entry/terminal width. Keep
134
+ // the two most recent widths so resize-back does not immediately reparse the
135
+ // whole visible history, while bounding cache growth during repeated resizes.
136
+ const rowCache = useRef<WeakMap<AnyEntry, Map<number, ScreenRow[]>>>(new WeakMap())
137
+ const rowsForEntry = useCallback((entry: AnyEntry): readonly ScreenRow[] => {
138
+ let widths = rowCache.current.get(entry)
139
+ if (!widths) {
140
+ widths = new Map()
141
+ rowCache.current.set(entry, widths)
142
+ }
143
+ const cached = widths.get(terminal.columns)
144
+ if (cached) return cached
145
+ const rows = entryScreenRows(viewsForEntry(entry), terminal.columns)
146
+ if (widths.size >= 2) widths.delete(widths.keys().next().value!)
147
+ widths.set(terminal.columns, rows)
148
+ return rows
149
+ }, [terminal.columns])
150
+ const rowAccess = useMemo(() => createRowAccess(
151
+ transcriptEntries,
152
+ // UUID is stable across SSE history replay; __id is only the local fallback
153
+ // for entries that do not carry a backend identity (notably the optimistic
154
+ // pending user row).
155
+ (entry, index) => String(entry.uuid ?? entry.__id ?? `entry-${index}`),
156
+ (entry) => rowsForEntry(entry),
157
+ ), [transcriptEntries, rowsForEntry])
125
158
  const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
126
159
  const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
127
160
  const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
128
- const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 3)
129
- const fitted = useMemo(
130
- () => fitTranscript(dedupedEntries, transcriptRows, terminal.columns, scrollBack),
131
- [dedupedEntries, transcriptRows, terminal.columns, scrollBack],
161
+ // Conversation chrome is exactly two rows: compact header + navigation.
162
+ const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 2)
163
+ const tail = useMemo(() => tailAnchor(rowAccess, transcriptRows), [rowAccess, transcriptRows])
164
+ const effectiveAnchor = rowAnchor ?? tail
165
+ const viewport = useMemo(
166
+ () => sliceViewport(rowAccess, effectiveAnchor, transcriptRows),
167
+ [rowAccess, effectiveAnchor, transcriptRows],
132
168
  )
133
- const showWelcome = dedupedEntries.length === 0 && chat.pendingUser === null && scrollBack === 0
134
-
135
- // Keep a history page pinned while new events stream in. At the latest page,
136
- // new output continues to auto-follow as usual.
137
- const prevLenRef = useRef(dedupedEntries.length)
138
- useEffect(() => {
139
- const previous = prevLenRef.current
140
- const current = dedupedEntries.length
141
- prevLenRef.current = current
142
- if (current > previous && scrollBack > 0) {
143
- setScrollBack(value => value + current - previous)
144
- } else if (scrollBack > current) {
145
- setScrollBack(current)
146
- }
147
- }, [dedupedEntries.length, scrollBack])
169
+ const showWelcome = transcriptEntries.length === 0
170
+ const pageRows = Math.max(1, transcriptRows - 1)
171
+
172
+ const scrollRows = useCallback((deltaRows: number) => {
173
+ if (deltaRows === 0) return
174
+ selState.current = null
175
+ setSel(null)
176
+ setRowAnchor(previous => {
177
+ const start = previous ?? tailAnchor(rowAccess, transcriptRows)
178
+ const next = moveAnchorByRows(rowAccess, start, deltaRows)
179
+ if (deltaRows > 0 && !sliceViewport(rowAccess, next, transcriptRows).hasNewer) return null
180
+ return next
181
+ })
182
+ }, [rowAccess, transcriptRows])
148
183
 
149
184
  // Show the model's friendly label (e.g. "GPT-5.6-Sol") in the header/status
150
185
  // instead of its opaque key (e.g. "codex:mobiusdefaultaabb").
@@ -170,16 +205,13 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
170
205
  if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
171
206
  return
172
207
  }
173
- const step = Math.max(1, fitted.entries.length)
174
- if (key.pageUp) setScrollBack(value => Math.min(dedupedEntries.length, value + step))
175
- else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
176
- })
208
+ if (key.pageUp) scrollRows(-pageRows)
209
+ else if (key.pageDown) scrollRows(pageRows)
210
+ }, { interactive: false })
177
211
 
178
- const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
179
- ? fitted.hiddenOlder > 0
180
- ? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp 翻页 · 拖动选中文本`
181
- : '已到最早记录 · 滚轮/PageDown 翻页 · 拖动选中文本'
182
- : null
212
+ const navigationHint = viewport.hasOlder
213
+ ? `${viewport.hasNewer ? '↑ 较早内容 · ↓ 较新内容' : '↑ 还有较早内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
214
+ : `${viewport.hasNewer ? '已到最早 · ↓ 还有较新内容' : '全部内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} · 拖动选中文本`
183
215
 
184
216
  // Mouse: wheel pages through history in small fixed steps, and a left-button
185
217
  // drag selects transcript text (tmux-style: the app owns the mouse, draws its
@@ -191,23 +223,16 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
191
223
  const [sel, setSel] = useState<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
192
224
  const [copyNotice, setCopyNotice] = useState<string | null>(null)
193
225
 
194
- // Geometry + text model must mirror the rendered transcript so a screen
195
- // (row, col) maps to the right entry/line/char. Recompute with the fitted view.
196
- const tipShown = dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
197
- const geometry: TranscriptGeometry = useMemo(() => computeTranscriptGeometry({
198
- viewportRows,
199
- composerRows,
200
- statusRows: STATUS_ROWS,
201
- activityRows,
202
- helpRows,
203
- showWelcome,
204
- welcomeRows: 10,
205
- olderHintShown: olderHint !== null,
206
- tipShown,
207
- }), [viewportRows, composerRows, activityRows, helpRows, showWelcome, olderHint, tipShown])
226
+ // Selection uses the exact virtual rows mounted below. There is no separate
227
+ // fitting/geometry pass, so hit-testing, rendering and clipboard extraction
228
+ // cannot disagree about which rows are on screen.
229
+ const geometry: TranscriptGeometry = useMemo(
230
+ () => ({ boxTop: 2, boxH: transcriptRows }),
231
+ [transcriptRows],
232
+ )
208
233
  const transcriptModel: TranscriptModel = useMemo(
209
- () => buildTranscriptModel(fitted.entries, terminal.columns),
210
- [fitted.entries, terminal.columns],
234
+ () => ({ entries: viewport.rows.map(item => [item.row.plain]), totalRows: viewport.rows.length }),
235
+ [viewport.rows],
211
236
  )
212
237
  const selMap = useMemo(
213
238
  () => (sel?.active ? buildSelectionMap(transcriptModel, sel.anchor, sel.end) : null),
@@ -223,25 +248,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
223
248
 
224
249
  useMouseEvents({
225
250
  onWheel: (delta) => {
226
- if (delta === 0) return
227
- const targetLines = 2
228
- let lines = 0
229
- let next = scrollBack
230
- const n = dedupedEntries.length
231
- if (delta > 0) {
232
- // scroll up (older): hide more entries from the tail
233
- for (let i = n - 1 - next; i >= 0 && lines < targetLines; i--) {
234
- lines += getEntryLines(i)
235
- next++
236
- }
237
- } else {
238
- // scroll down (newer): unhide entries from the tail
239
- for (let i = n - next; i < n && lines < targetLines; i++) {
240
- lines += getEntryLines(i)
241
- next--
242
- }
243
- }
244
- setScrollBack(Math.min(n, Math.max(0, next)))
251
+ scrollRows(delta * -3)
245
252
  },
246
253
  onPress: (row, col) => {
247
254
  const p = screenToSelPoint(row, col, transcriptModel, geometry)
@@ -321,32 +328,25 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
321
328
  ? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
322
329
  : <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
323
330
 
324
- {/* Older-records hint is pinned OUTSIDE the flex-end scroll box so it is
325
- always the first line of the transcript, spanning the full width,
326
- instead of floating mid-screen when the transcript has spare rows. */}
327
- {olderHint !== null
328
- ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {olderHint}</Text></Box>
331
+ {!showWelcome
332
+ ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> {navigationHint}</Text></Box>
329
333
  : null}
330
334
 
331
- <Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome || fitted.hiddenOlder > 0 ? 'flex-start' : 'flex-end'} overflowY="hidden">
332
- {fitted.peekLines.length > 0
333
- ? <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>
336
- ))}
337
- </Box>
338
- : null}
339
- {fitted.entries.map((entry, index) => {
340
- const entrySel = selMap?.get(index)
341
- 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} />
345
- })}
346
- {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
347
- {fitted.hiddenRecent > 0
348
- ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> ↓ 滚轮/PageDown 翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
349
- : null}
335
+ <Box
336
+ height={showWelcome ? undefined : transcriptRows}
337
+ flexGrow={showWelcome ? 1 : 0}
338
+ flexShrink={showWelcome ? 1 : 0}
339
+ flexDirection="column"
340
+ justifyContent="flex-end"
341
+ overflowY="hidden"
342
+ >
343
+ {!showWelcome ? viewport.rows.map((item, index) => {
344
+ const range = selMap?.get(index)?.get(0)
345
+ const text = range && range.start < range.end
346
+ ? highlightScreenRow(item.row.styled, range.start, range.end)
347
+ : item.row.styled
348
+ return <ScreenText key={`${item.entryId}:${item.rowIndex}`} row={item.row} text={text || ' '} />
349
+ }) : null}
350
350
  </Box>
351
351
 
352
352
  {dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
@@ -367,6 +367,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
367
367
  typing={chat.typing}
368
368
  commands={SLASH_COMMANDS}
369
369
  onHeightChange={setComposerRows}
370
+ inputActiveRef={chatInputActiveRef}
370
371
  />
371
372
  <StatusArea
372
373
  ready={ready}
@@ -410,7 +411,7 @@ function WelcomeCard({ ready, columns, resumed, modelDisplay }: { ready: ReadySt
410
411
  <Text>
411
412
  <Text dimColor>{'>_ '}</Text>
412
413
  <Text bold>Mobius</Text>
413
- <Text dimColor> (v{VERSION})</Text>
414
+ <Text dimColor> (v{TUI_VERSION})</Text>
414
415
  </Text>
415
416
  <Text> </Text>
416
417
  <MetaRow label="model:" value={modelDisplay} hint="/help 查看命令" labelWidth={labelWidth} />
@@ -445,154 +446,58 @@ function CompactHeader({ ready, sessionId, columns }: { ready: ReadyState; sessi
445
446
  )
446
447
  }
447
448
 
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
- )
449
+ function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
450
+ const tone = row.tone
451
+ const color = tone === 'tool' ? 'cyan'
452
+ : tone === 'tool_error' || tone === 'edit_old' || tone === 'error' ? 'red'
453
+ : tone === 'edit_header' || tone === 'reasoning' ? 'magenta'
454
+ : tone === 'edit_new' ? 'green'
455
+ : tone === 'system' ? 'yellow'
456
+ : undefined
457
+ const dimColor = tone === 'tool_result' || tone === 'tool_error' || tone === 'reasoning' || tone === 'system'
458
+ return <Text wrap="truncate-end" bold={tone === 'user'} dimColor={dimColor} color={color}>{text}</Text>
455
459
  }
456
460
 
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 }: {
462
- entry: AnyEntry
463
- columns: number
464
- sel: Map<number, { start: number; end: number }>
465
- }) {
466
- const lines = entryScreenLines(viewsForEntry(entry), columns)
467
- return (
468
- <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>
481
- })}
482
- </Box>
483
- )
484
- }
485
-
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
- )
461
+ const ANSI_CSI_RE = /^\x1b\[[0-?]*[ -/]*[@-~]/
462
+ const SELECTION_BG = '\x1b[46m'
463
+ const SELECTION_BG_END = '\x1b[49m'
464
+
465
+ /** Add a background to visible UTF-16 offsets without stripping existing ANSI. */
466
+ function highlightScreenRow(styled: string, start: number, end: number): string {
467
+ if (start >= end) return styled
468
+ let out = ''
469
+ let raw = 0
470
+ let visible = 0
471
+ let highlighted = false
472
+ while (raw < styled.length) {
473
+ if (styled.charCodeAt(raw) === 0x1b) {
474
+ const match = ANSI_CSI_RE.exec(styled.slice(raw))
475
+ if (match) {
476
+ out += match[0]
477
+ raw += match[0].length
478
+ // A full SGR reset inside Markdown/syntax text also resets the injected
479
+ // background; immediately restore it while the selected span is active.
480
+ if (highlighted && /^\x1b\[(?:0)?m$/.test(match[0])) out += SELECTION_BG
481
+ continue
482
+ }
504
483
  }
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
- )
484
+ const codePoint = styled.codePointAt(raw)!
485
+ const char = String.fromCodePoint(codePoint)
486
+ const nextVisible = visible + char.length
487
+ if (!highlighted && nextVisible > start && visible < end) {
488
+ out += SELECTION_BG
489
+ highlighted = true
518
490
  }
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
- )
491
+ if (highlighted && visible >= end) {
492
+ out += SELECTION_BG_END
493
+ highlighted = false
530
494
  }
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
495
+ out += char
496
+ raw += char.length
497
+ visible = nextVisible
557
498
  }
558
- }
559
-
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
- function UserLine({ text }: { text: string }) {
592
- const lines = text.split('\n')
593
- if (lines[0] !== undefined) lines[0] = `› ${lines[0]}`
594
- for (let i = 1; i < lines.length; i++) lines[i] = ` ${lines[i]}`
595
- return <Box marginTop={1}><Text bold>{lines.join('\n')}</Text></Box>
499
+ if (highlighted) out += SELECTION_BG_END
500
+ return out
596
501
  }
597
502
 
598
503
  function WorkingIndicator({ firstQuery }: { firstQuery: boolean }) {
@@ -653,9 +558,10 @@ interface ComposerProps {
653
558
  typing: boolean
654
559
  commands: { cmd: string; desc: string }[]
655
560
  onHeightChange?: (rows: number) => void
561
+ inputActiveRef?: React.RefObject<boolean>
656
562
  }
657
563
 
658
- export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange }: ComposerProps) {
564
+ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange, inputActiveRef }: ComposerProps) {
659
565
  const [value, setValue] = useState('')
660
566
  const [cursor, setCursor] = useState(0)
661
567
  const [popupIdx, setPopupIdx] = useState(0)
@@ -799,6 +705,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
799
705
  useEffect(() => () => resetPasteBurst(), [])
800
706
 
801
707
  useStableInput((input, key) => {
708
+ if (inputActiveRef?.current === false) return false
802
709
  if (isMouseInput(input)) return // mouse events must never become typed text
803
710
  const now = Date.now()
804
711
  const escape = isEscapeKeypress(input, key)
@@ -1005,7 +912,7 @@ function normalizeComposerPaste(text: string): string {
1005
912
  }
1006
913
 
1007
914
  function isEnhancedNewlineInput(input: string): boolean {
1008
- return /^\[(?:13|27);2(?:u|~)$/.test(input) || input === '\x1b\r'
915
+ return /^\[13;2u$/.test(input) || /^\[27;2;13~$/.test(input) || input === '\x1b\r'
1009
916
  }
1010
917
 
1011
918
  function findPasteMarker(input: string, code: '200' | '201', from = 0): number {
@@ -1161,93 +1068,3 @@ function clickableUrl(url: string, maxLen?: number): string {
1161
1068
 
1162
1069
  // displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
1163
1070
  // here to size the AIMUX status block so the web URL truncates exactly.
1164
-
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): {
1199
- entries: AnyEntry[]
1200
- /** Tail rows of the next older entry, used to fill spare space above the viewport. */
1201
- peekLines: string[]
1202
- hiddenOlder: number
1203
- hiddenRecent: number
1204
- startIndex: number
1205
- } {
1206
- const tail = Math.max(0, entries.length - scrollBack)
1207
- const available = tail === 0 ? [] : entries.slice(0, tail)
1208
- const renderedRows = available.map((entry) => entryScreenLines(viewsForEntry(entry), columns))
1209
- const fit = (budget: number) => {
1210
- let rows = 0
1211
- let first = available.length
1212
- for (let index = available.length - 1; index >= 0; index--) {
1213
- const nextRows = renderedRows[index].length
1214
- if (first < available.length && rows + nextRows > budget) break
1215
- rows += nextRows
1216
- first = index
1217
- }
1218
- return { first, rows }
1219
- }
1220
-
1221
- const base = fit(rowBudget)
1222
- let fitted = base
1223
- let first = fitted.first
1224
- let peekLines: string[] = []
1225
- // When older history exists, guarantee at least one row for the tail of the
1226
- // next older message. If complete entries exactly consume the budget, refit
1227
- // them with one fewer row; only the oldest complete entry can drop out, while
1228
- // the latest content remains visible. A single oversized entry keeps its
1229
- // original rendering because it cannot safely donate a row.
1230
- if (first > 0 && fitted.rows <= rowBudget) {
1231
- if (fitted.rows === rowBudget && rowBudget > 1) {
1232
- const reduced = fit(rowBudget - 1)
1233
- if (reduced.first > 0 && reduced.rows <= rowBudget - 1) {
1234
- fitted = reduced
1235
- first = reduced.first
1236
- }
1237
- }
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()
1241
- const spare = rowBudget - fitted.rows
1242
- if (spare > 0 && olderLines.length > 0) {
1243
- peekLines = olderLines.slice(-spare)
1244
- }
1245
- }
1246
- return {
1247
- entries: available.slice(first),
1248
- peekLines,
1249
- hiddenOlder: first,
1250
- hiddenRecent: entries.length - tail,
1251
- startIndex: first,
1252
- }
1253
- }
@@ -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>