@mobius-os/mobius 0.3.31 → 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.
package/README.md CHANGED
@@ -130,6 +130,13 @@ npm test # all three
130
130
 
131
131
  ## Build an installable package
132
132
 
133
+ The TUI release version has one source of truth: `mobius/tui/package.json`.
134
+ The welcome screen, npm package metadata, artifact filename, and download
135
+ manifest all read that value. `package-lock.json` mirrors it as generated npm
136
+ metadata; do not edit it as a separate release setting. The AIMUX bundle
137
+ version (`BUNDLE_VER`) is an independent Python runtime cache version and is
138
+ not the TUI version.
139
+
133
140
  From the repository root:
134
141
 
135
142
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.31",
3
+ "version": "0.3.34",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
@@ -19,6 +19,7 @@
19
19
  "test:reconnect": "tsx tests/reconnect.test.tsx",
20
20
  "test:screen": "FORCE_COLOR=1 tsx tests/screen.test.tsx",
21
21
  "test:scroll": "tsx tests/scroll.test.tsx",
22
+ "test:viewport": "tsx tests/viewport.test.ts",
22
23
  "test:selection": "FORCE_COLOR=1 tsx tests/selection.test.tsx",
23
24
  "test": "npm run typecheck && npm run test:ui && npm run test:integration"
24
25
  },
@@ -13,11 +13,14 @@ import { useChat } from '../hooks/useChat.js'
13
13
  import { MobiusClient } from '../api.js'
14
14
  import { viewsForEntry, dedupeUserEntries, isAssistantOutput } from '../lib/entry-view.js'
15
15
  import {
16
- displayWidth, compareSel, entryScreenLines, entryScreenRows,
17
- buildTranscriptModel, computeTranscriptGeometry, screenToSelPoint,
16
+ displayWidth, compareSel, entryScreenRows, screenToSelPoint,
18
17
  buildSelectionMap, buildSelectionText, osc52,
19
18
  type TranscriptModel, type TranscriptGeometry, type SelPoint, type ScreenRow,
20
19
  } from '../lib/screen-text.js'
20
+ import {
21
+ createRowAccess, moveAnchorByRows, sliceViewport, tailAnchor,
22
+ type RowAnchor,
23
+ } from '../lib/transcript-viewport.js'
21
24
  import type { ReadyState } from './PrepScreen.js'
22
25
  import type { AnyEntry } from '../types.js'
23
26
  import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
@@ -45,8 +48,7 @@ interface TerminalSize {
45
48
  isTty: boolean
46
49
  }
47
50
 
48
- import { createRequire } from 'node:module'
49
- const VERSION = createRequire(import.meta.url)('../../package.json').version
51
+ import { TUI_VERSION } from '../version.js'
50
52
  const DEFAULT_COMPOSER_ROWS = 5
51
53
  const STATUS_ROWS = 3
52
54
 
@@ -62,7 +64,9 @@ const SLASH_COMMANDS = [
62
64
  export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
63
65
  const chat = useChat({ client, ready, resumeSessionId })
64
66
  const [showHelp, setShowHelp] = useState(false)
65
- 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)
66
70
  const [composerRows, setComposerRows] = useState(DEFAULT_COMPOSER_ROWS)
67
71
  const [modelLabel, setModelLabel] = useState<string | null>(null)
68
72
  const [configOpen, setConfigOpen] = useState(false)
@@ -100,7 +104,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
100
104
  return
101
105
  }
102
106
  setShowHelp(false)
103
- setScrollBack(0)
107
+ setRowAnchor(null)
104
108
  void chat.send(t)
105
109
  }, [chat, runSlash])
106
110
 
@@ -116,39 +120,66 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
116
120
  // (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
117
121
  // 避免在累积视图里把同一条提问显示多次.
118
122
  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])
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])
129
158
  const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
130
159
  const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
131
160
  const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
132
- const transcriptRows = Math.max(1, viewportRows - composerRows - STATUS_ROWS - activityRows - helpRows - 3)
133
- const fitted = useMemo(
134
- () => fitTranscript(dedupedEntries, transcriptRows, terminal.columns, scrollBack),
135
- [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],
136
168
  )
137
- const showWelcome = dedupedEntries.length === 0 && chat.pendingUser === null && scrollBack === 0
138
-
139
- // Keep a history page pinned while new events stream in. At the latest page,
140
- // new output continues to auto-follow as usual.
141
- const prevLenRef = useRef(dedupedEntries.length)
142
- useEffect(() => {
143
- const previous = prevLenRef.current
144
- const current = dedupedEntries.length
145
- prevLenRef.current = current
146
- if (current > previous && scrollBack > 0) {
147
- setScrollBack(value => value + current - previous)
148
- } else if (scrollBack > current) {
149
- setScrollBack(current)
150
- }
151
- }, [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])
152
183
 
153
184
  // Show the model's friendly label (e.g. "GPT-5.6-Sol") in the header/status
154
185
  // instead of its opaque key (e.g. "codex:mobiusdefaultaabb").
@@ -174,16 +205,13 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
174
205
  if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
175
206
  return
176
207
  }
177
- const step = Math.max(1, fitted.entries.length)
178
- if (key.pageUp) setScrollBack(value => Math.min(dedupedEntries.length, value + step))
179
- else if (key.pageDown) setScrollBack(value => Math.max(0, value - step))
208
+ if (key.pageUp) scrollRows(-pageRows)
209
+ else if (key.pageDown) scrollRows(pageRows)
180
210
  }, { interactive: false })
181
211
 
182
- const olderHint = !showWelcome && (fitted.hiddenOlder > 0 || scrollBack > 0)
183
- ? fitted.hiddenOlder > 0
184
- ? `↑ 还有 ${fitted.hiddenOlder} 条较早记录 · 滚轮/PageUp 翻页 · 拖动选中文本`
185
- : '已到最早记录 · 滚轮/PageDown 翻页 · 拖动选中文本'
186
- : null
212
+ const navigationHint = viewport.hasOlder
213
+ ? `${viewport.hasNewer ? '↑ 较早内容 · ↓ 较新内容' : '↑ 还有较早内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} 行 · 拖动选中文本`
214
+ : `${viewport.hasNewer ? '已到最早 · ↓ 还有较新内容' : '全部内容'} · 滚轮 3 行 · PageUp/PageDown ${pageRows} · 拖动选中文本`
187
215
 
188
216
  // Mouse: wheel pages through history in small fixed steps, and a left-button
189
217
  // drag selects transcript text (tmux-style: the app owns the mouse, draws its
@@ -195,23 +223,16 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
195
223
  const [sel, setSel] = useState<{ anchor: SelPoint; end: SelPoint; active: boolean } | null>(null)
196
224
  const [copyNotice, setCopyNotice] = useState<string | null>(null)
197
225
 
198
- // Geometry + text model must mirror the rendered transcript so a screen
199
- // (row, col) maps to the right entry/line/char. Recompute with the fitted view.
200
- const tipShown = dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
201
- const geometry: TranscriptGeometry = useMemo(() => computeTranscriptGeometry({
202
- viewportRows,
203
- composerRows,
204
- statusRows: STATUS_ROWS,
205
- activityRows,
206
- helpRows,
207
- showWelcome,
208
- welcomeRows: 10,
209
- olderHintShown: olderHint !== null,
210
- tipShown,
211
- }), [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
+ )
212
233
  const transcriptModel: TranscriptModel = useMemo(
213
- () => buildTranscriptModel(fitted.entries, terminal.columns),
214
- [fitted.entries, terminal.columns],
234
+ () => ({ entries: viewport.rows.map(item => [item.row.plain]), totalRows: viewport.rows.length }),
235
+ [viewport.rows],
215
236
  )
216
237
  const selMap = useMemo(
217
238
  () => (sel?.active ? buildSelectionMap(transcriptModel, sel.anchor, sel.end) : null),
@@ -227,25 +248,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
227
248
 
228
249
  useMouseEvents({
229
250
  onWheel: (delta) => {
230
- if (delta === 0) return
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)))
251
+ scrollRows(delta * -3)
249
252
  },
250
253
  onPress: (row, col) => {
251
254
  const p = screenToSelPoint(row, col, transcriptModel, geometry)
@@ -325,34 +328,25 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
325
328
  ? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
326
329
  : <CompactHeader ready={ready} sessionId={chat.sessionId} columns={terminal.columns} />}
327
330
 
328
- {/* Older-records hint is pinned OUTSIDE the flex-end scroll box so it is
329
- always the first line of the transcript, spanning the full width,
330
- instead of floating mid-screen when the transcript has spare rows. */}
331
- {olderHint !== null
332
- ? <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>
333
333
  : null}
334
334
 
335
- <Box flexGrow={1} flexShrink={1} flexDirection="column" justifyContent={showWelcome || fitted.hiddenOlder > 0 ? 'flex-start' : 'flex-end'} overflowY="hidden">
336
- {fitted.peekRows.length > 0
337
- ? <Box width="100%" flexShrink={0} flexDirection="column">
338
- {fitted.peekRows.map((row, index) => (
339
- <ScreenText
340
- key={`peek-${index}`}
341
- row={row}
342
- text={`${index === 0 ? ' ⋯ ' : ' '}${row.styled}`}
343
- />
344
- ))}
345
- </Box>
346
- : null}
347
- {fitted.entries.map((entry, index) => {
348
- const entrySel = selMap?.get(index)
349
- const key = entry.__id ?? `entry-${fitted.startIndex + index}`
350
- return <EntryScreen key={key} entry={entry} columns={terminal.columns} sel={entrySel} />
351
- })}
352
- {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
353
- {fitted.hiddenRecent > 0
354
- ? <Box width="100%" flexShrink={0}><Text dimColor wrap="truncate-end"> ↓ 滚轮/PageDown 翻页 · 较新 {fitted.hiddenRecent} 条</Text></Box>
355
- : 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}
356
350
  </Box>
357
351
 
358
352
  {dedupedEntries.length === 0 && chat.pendingUser === null && !showHelp
@@ -417,7 +411,7 @@ function WelcomeCard({ ready, columns, resumed, modelDisplay }: { ready: ReadySt
417
411
  <Text>
418
412
  <Text dimColor>{'>_ '}</Text>
419
413
  <Text bold>Mobius</Text>
420
- <Text dimColor> (v{VERSION})</Text>
414
+ <Text dimColor> (v{TUI_VERSION})</Text>
421
415
  </Text>
422
416
  <Text> </Text>
423
417
  <MetaRow label="model:" value={modelDisplay} hint="/help 查看命令" labelWidth={labelWidth} />
@@ -452,29 +446,6 @@ function CompactHeader({ ready, sessionId, columns }: { ready: ReadyState; sessi
452
446
  )
453
447
  }
454
448
 
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 }: {
460
- entry: AnyEntry
461
- columns: number
462
- sel?: Map<number, { start: number; end: number }>
463
- }) {
464
- const rows = entryScreenRows(viewsForEntry(entry), columns)
465
- return (
466
- <Box flexDirection="column">
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 || ' '} />
473
- })}
474
- </Box>
475
- )
476
- }
477
-
478
449
  function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
479
450
  const tone = row.tone
480
451
  const color = tone === 'tool' ? 'cyan'
@@ -529,13 +500,6 @@ function highlightScreenRow(styled: string, start: number, end: number): string
529
500
  return out
530
501
  }
531
502
 
532
- function UserLine({ text }: { text: string }) {
533
- const lines = text.split('\n')
534
- if (lines[0] !== undefined) lines[0] = `› ${lines[0]}`
535
- for (let i = 1; i < lines.length; i++) lines[i] = ` ${lines[i]}`
536
- return <Box marginTop={1}><Text bold>{lines.join('\n')}</Text></Box>
537
- }
538
-
539
503
  function WorkingIndicator({ firstQuery }: { firstQuery: boolean }) {
540
504
  const startedAt = useRef(Date.now())
541
505
  const [animationFrame, setAnimationFrame] = useState(0)
@@ -1104,60 +1068,3 @@ function clickableUrl(url: string, maxLen?: number): string {
1104
1068
 
1105
1069
  // displayWidth is imported from src/lib/screen-text.ts (CJK/emoji-aware), used
1106
1070
  // here to size the AIMUX status block so the web URL truncates exactly.
1107
-
1108
- export function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
1109
- entries: AnyEntry[]
1110
- /** Tail rows of the next older entry, used to fill spare space above the viewport. */
1111
- peekRows: ScreenRow[]
1112
- hiddenOlder: number
1113
- hiddenRecent: number
1114
- startIndex: number
1115
- } {
1116
- const tail = Math.max(0, entries.length - scrollBack)
1117
- const available = tail === 0 ? [] : entries.slice(0, tail)
1118
- const renderedRows = available.map((entry) => entryScreenRows(viewsForEntry(entry), columns))
1119
- const fit = (budget: number) => {
1120
- let rows = 0
1121
- let first = available.length
1122
- for (let index = available.length - 1; index >= 0; index--) {
1123
- const nextRows = renderedRows[index].length
1124
- if (first < available.length && rows + nextRows > budget) break
1125
- rows += nextRows
1126
- first = index
1127
- }
1128
- return { first, rows }
1129
- }
1130
-
1131
- const base = fit(rowBudget)
1132
- let fitted = base
1133
- let first = fitted.first
1134
- let peekRows: ScreenRow[] = []
1135
- // When older history exists, guarantee at least one row for the tail of the
1136
- // next older message. If complete entries exactly consume the budget, refit
1137
- // them with one fewer row; only the oldest complete entry can drop out, while
1138
- // the latest content remains visible. A single oversized entry keeps its
1139
- // original rendering because it cannot safely donate a row.
1140
- if (first > 0 && fitted.rows <= rowBudget) {
1141
- if (fitted.rows === rowBudget && rowBudget > 1) {
1142
- const reduced = fit(rowBudget - 1)
1143
- if (reduced.first > 0 && reduced.rows <= rowBudget - 1) {
1144
- fitted = reduced
1145
- first = reduced.first
1146
- }
1147
- }
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()
1151
- const spare = rowBudget - fitted.rows
1152
- if (spare > 0 && olderRows.length > 0) {
1153
- peekRows = olderRows.slice(-spare)
1154
- }
1155
- }
1156
- return {
1157
- entries: available.slice(first),
1158
- peekRows,
1159
- hiddenOlder: first,
1160
- hiddenRecent: entries.length - tail,
1161
- startIndex: first,
1162
- }
1163
- }
@@ -192,6 +192,26 @@ export function parseMouseEvents(input: string): MouseEventInfo[] {
192
192
  return out
193
193
  }
194
194
 
195
+ /** Collapse each contiguous wheel burst into one delta without reordering clicks. */
196
+ export function coalesceMouseEvents(events: MouseEventInfo[]): MouseEventInfo[] {
197
+ const out: MouseEventInfo[] = []
198
+ let wheelDelta = 0
199
+ const flushWheel = () => {
200
+ if (wheelDelta !== 0) out.push({ kind: 'wheel', delta: wheelDelta })
201
+ wheelDelta = 0
202
+ }
203
+ for (const event of events) {
204
+ if (event.kind === 'wheel') {
205
+ wheelDelta += event.delta
206
+ } else {
207
+ flushWheel()
208
+ out.push(event)
209
+ }
210
+ }
211
+ flushWheel()
212
+ return out
213
+ }
214
+
195
215
  /**
196
216
  * Enables terminal mouse tracking for the lifetime of the calling component and
197
217
  * forwards mouse events (wheel + left-button press/motion/release) to the given
@@ -228,7 +248,7 @@ export function useMouseEvents(handlers: {
228
248
  // A single read() chunk may carry several events and a sequence may be
229
249
  // split across chunks, so accumulate and re-scan.
230
250
  buf += String(chunk)
231
- for (const e of parseMouseEvents(buf)) {
251
+ for (const e of coalesceMouseEvents(parseMouseEvents(buf))) {
232
252
  if (e.kind === 'wheel') refs.current.onWheel?.(e.delta)
233
253
  else if (e.kind === 'press') refs.current.onPress?.(e.row, e.col)
234
254
  else if (e.kind === 'motion') refs.current.onMotion?.(e.row, e.col)
@@ -638,18 +638,18 @@ export function toolLabel(name: string): string {
638
638
  // agent 输出, 则视为同一次输入的重复入口 → 丢弃, 避免 TUI 把同一条提问显示多次.
639
639
  export function userTextOf(e: AnyEntry): string {
640
640
  if (e?.type === 'event_msg' && e?.payload?.type === 'user_message') {
641
- return String(e?.payload?.message || '').trim()
641
+ return canonicalUserText(String(e?.payload?.message || ''))
642
642
  }
643
643
  if (e?.type === 'response_item' && e?.payload?.type === 'message' && e?.payload?.role === 'user') {
644
644
  const c = e?.payload?.content
645
- if (typeof c === 'string') return c.trim()
646
- if (Array.isArray(c)) return c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n').trim()
645
+ if (typeof c === 'string') return canonicalUserText(c)
646
+ if (Array.isArray(c)) return canonicalUserText(c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n'))
647
647
  return ''
648
648
  }
649
649
  if (e?.type === 'user') {
650
650
  const c = e?.message?.content
651
- if (typeof c === 'string') return c.trim()
652
- if (Array.isArray(c)) return c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n').trim()
651
+ if (typeof c === 'string') return canonicalUserText(c)
652
+ if (Array.isArray(c)) return canonicalUserText(c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n'))
653
653
  return ''
654
654
  }
655
655
  return ''
@@ -669,6 +669,11 @@ export function stripUserFraming(text: string): string {
669
669
  return after || text
670
670
  }
671
671
 
672
+ /** Canonical user text for duplicate event identities (framed vs plain). */
673
+ function canonicalUserText(text: string): string {
674
+ return stripUserFraming(text).replace(/\s+/g, ' ').trim()
675
+ }
676
+
672
677
  export function isAssistantOutput(e: AnyEntry): boolean {
673
678
  if (e?.type === 'assistant') return true
674
679
  if (e?.type === 'event_msg' && e?.payload?.type === 'agent_message') return true
@@ -13,7 +13,6 @@
13
13
  import wrapAnsi from 'wrap-ansi'
14
14
  import { renderMarkdownLines } from '../markdown.js'
15
15
  import { toolLabel, viewsForEntry, type EntryView } from './entry-view.js'
16
- import type { AnyEntry } from '../types.js'
17
16
 
18
17
  // ── shared text helpers (mirrored from Chat.tsx, kept here to avoid a cycle) ─
19
18
  export function displayWidth(str: string): number {
@@ -221,11 +220,6 @@ export function entryScreenRows(views: EntryView[], columns: number): ScreenRow[
221
220
  return lines
222
221
  }
223
222
 
224
- /** Plain projection used by fitting, geometry, hit-testing, and copying. */
225
- export function entryScreenLines(views: EntryView[], columns: number): string[] {
226
- return entryScreenRows(views, columns).map(row => row.plain)
227
- }
228
-
229
223
  // ── vertical geometry ────────────────────────────────────────────────────────
230
224
  export interface TranscriptGeometry {
231
225
  /** Screen row where the transcript box's top edge sits. */
@@ -240,30 +234,6 @@ export interface TranscriptGeometry {
240
234
  * (`flexShrink=0`); the middle column holds header + hint + transcript (flexGrow)
241
235
  * + tip + help. Margins that render as extra rows are counted explicitly.
242
236
  */
243
- export function computeTranscriptGeometry(opts: {
244
- viewportRows: number
245
- composerRows: number
246
- statusRows: number
247
- activityRows: number
248
- helpRows: number
249
- showWelcome: boolean
250
- welcomeRows: number
251
- olderHintShown: boolean
252
- tipShown: boolean
253
- }): TranscriptGeometry {
254
- // The composer's reported height already includes its marginTop; the status
255
- // area and working indicator rows are already folded into statusRows and
256
- // activityRows. No extra +1 here — calibrated against the rendered frame.
257
- const bottomH = opts.activityRows + opts.composerRows + opts.statusRows
258
- const midH = opts.viewportRows - bottomH
259
- const headerH = opts.showWelcome ? opts.welcomeRows : 1
260
- const hintH = opts.olderHintShown ? 1 : 0
261
- const tipH = opts.tipShown ? 2 : 0 // marginTop 1 + content 1
262
- const helpH = opts.helpRows > 0 ? opts.helpRows + 1 : 0 // +1 marginTop
263
- const boxTop = headerH + hintH + tipH + helpH
264
- return { boxTop, boxH: Math.max(0, midH - boxTop) }
265
- }
266
-
267
237
  // ── selection mapping ────────────────────────────────────────────────────────
268
238
  export interface SelPoint {
269
239
  entry: number // index into the fitted entries
@@ -276,11 +246,6 @@ export interface TranscriptModel {
276
246
  totalRows: number
277
247
  }
278
248
 
279
- export function buildTranscriptModel(fittedEntries: AnyEntry[], columns: number): TranscriptModel {
280
- const entries = fittedEntries.map((e) => entryScreenLines(viewsForEntry(e), columns))
281
- return { entries, totalRows: entries.reduce((sum, l) => sum + l.length, 0) }
282
- }
283
-
284
249
  /** Convert a screen (row, col) into a SelPoint, or null if outside the transcript. */
285
250
  export function screenToSelPoint(
286
251
  screenRow: number,
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Pure row-level transcript viewport.
3
+ *
4
+ * Entries are materialized lazily through RowAccess.rowsAt(). Navigation keeps
5
+ * an entry/row anchor, so moving by N rows is exact even when one entry is much
6
+ * taller than the terminal. No React or Ink dependency belongs in this file.
7
+ */
8
+
9
+ export interface RowAnchor {
10
+ entryId: string
11
+ entryIndex: number
12
+ rowIndex: number
13
+ }
14
+
15
+ export interface ViewportRow<T> {
16
+ entryId: string
17
+ entryIndex: number
18
+ rowIndex: number
19
+ row: T
20
+ }
21
+
22
+ export interface RowAccess<T> {
23
+ length: number
24
+ idAt: (index: number) => string
25
+ indexOf: (entryId: string) => number
26
+ rowsAt: (index: number) => readonly T[]
27
+ }
28
+
29
+ export interface ViewportSlice<T> {
30
+ anchor: RowAnchor | null
31
+ rows: ViewportRow<T>[]
32
+ hasOlder: boolean
33
+ hasNewer: boolean
34
+ }
35
+
36
+ export function createRowAccess<E, T>(
37
+ entries: readonly E[],
38
+ getId: (entry: E, index: number) => string,
39
+ getRows: (entry: E, index: number) => readonly T[],
40
+ ): RowAccess<T> {
41
+ const ids = entries.map(getId)
42
+ const indexById = new Map(ids.map((id, index) => [id, index]))
43
+ return {
44
+ length: entries.length,
45
+ idAt: (index) => ids[index] ?? '',
46
+ indexOf: (entryId) => indexById.get(entryId) ?? -1,
47
+ rowsAt: (index) => index >= 0 && index < entries.length ? getRows(entries[index], index) : [],
48
+ }
49
+ }
50
+
51
+ function previousNonEmpty<T>(access: RowAccess<T>, from: number): number {
52
+ for (let index = from; index >= 0; index--) {
53
+ if (access.rowsAt(index).length > 0) return index
54
+ }
55
+ return -1
56
+ }
57
+
58
+ function nextNonEmpty<T>(access: RowAccess<T>, from: number): number {
59
+ for (let index = from; index < access.length; index++) {
60
+ if (access.rowsAt(index).length > 0) return index
61
+ }
62
+ return -1
63
+ }
64
+
65
+ function resolveAnchor<T>(access: RowAccess<T>, anchor: RowAnchor | null): RowAnchor | null {
66
+ if (!anchor || access.length === 0) return null
67
+ let entryIndex = access.indexOf(anchor.entryId)
68
+ if (entryIndex < 0) entryIndex = Math.max(0, Math.min(access.length - 1, anchor.entryIndex))
69
+
70
+ let rows = access.rowsAt(entryIndex)
71
+ if (rows.length === 0) {
72
+ const next = nextNonEmpty(access, entryIndex + 1)
73
+ const previous = previousNonEmpty(access, entryIndex - 1)
74
+ entryIndex = next >= 0 ? next : previous
75
+ if (entryIndex < 0) return null
76
+ rows = access.rowsAt(entryIndex)
77
+ }
78
+
79
+ return {
80
+ entryId: access.idAt(entryIndex),
81
+ entryIndex,
82
+ rowIndex: Math.max(0, Math.min(rows.length - 1, anchor.rowIndex)),
83
+ }
84
+ }
85
+
86
+ export function tailAnchor<T>(access: RowAccess<T>, viewportRows: number): RowAnchor | null {
87
+ let remaining = Math.max(1, Math.floor(viewportRows))
88
+ let first: RowAnchor | null = null
89
+ for (let entryIndex = access.length - 1; entryIndex >= 0; entryIndex--) {
90
+ const rows = access.rowsAt(entryIndex)
91
+ if (rows.length === 0) continue
92
+ first = { entryId: access.idAt(entryIndex), entryIndex, rowIndex: 0 }
93
+ if (rows.length >= remaining) {
94
+ return { entryId: access.idAt(entryIndex), entryIndex, rowIndex: rows.length - remaining }
95
+ }
96
+ remaining -= rows.length
97
+ }
98
+ return first
99
+ }
100
+
101
+ /** Move the top-row anchor by an exact signed row delta. Positive means newer. */
102
+ export function moveAnchorByRows<T>(access: RowAccess<T>, anchor: RowAnchor | null, deltaRows: number): RowAnchor | null {
103
+ const resolved = resolveAnchor(access, anchor)
104
+ if (!resolved || deltaRows === 0) return resolved
105
+
106
+ let entryIndex = resolved.entryIndex
107
+ let rowIndex = resolved.rowIndex
108
+ let remaining = Math.abs(Math.trunc(deltaRows))
109
+
110
+ if (deltaRows > 0) {
111
+ while (remaining > 0) {
112
+ const rows = access.rowsAt(entryIndex)
113
+ const within = rows.length - 1 - rowIndex
114
+ if (remaining <= within) { rowIndex += remaining; remaining = 0; break }
115
+ remaining -= within
116
+ const next = nextNonEmpty(access, entryIndex + 1)
117
+ if (next < 0) { rowIndex = rows.length - 1; break }
118
+ entryIndex = next
119
+ rowIndex = 0
120
+ remaining -= 1
121
+ }
122
+ } else {
123
+ while (remaining > 0) {
124
+ if (remaining <= rowIndex) { rowIndex -= remaining; remaining = 0; break }
125
+ remaining -= rowIndex
126
+ const previous = previousNonEmpty(access, entryIndex - 1)
127
+ if (previous < 0) { rowIndex = 0; break }
128
+ entryIndex = previous
129
+ rowIndex = access.rowsAt(entryIndex).length - 1
130
+ remaining -= 1
131
+ }
132
+ }
133
+
134
+ return { entryId: access.idAt(entryIndex), entryIndex, rowIndex }
135
+ }
136
+
137
+ export function sliceViewport<T>(access: RowAccess<T>, anchor: RowAnchor | null, viewportRows: number): ViewportSlice<T> {
138
+ const resolved = resolveAnchor(access, anchor)
139
+ const limit = Math.max(0, Math.floor(viewportRows))
140
+ if (!resolved || limit === 0) return { anchor: resolved, rows: [], hasOlder: false, hasNewer: false }
141
+
142
+ const visible: ViewportRow<T>[] = []
143
+ let entryIndex = resolved.entryIndex
144
+ let rowIndex = resolved.rowIndex
145
+ while (entryIndex < access.length && visible.length < limit) {
146
+ const rows = access.rowsAt(entryIndex)
147
+ for (; rowIndex < rows.length && visible.length < limit; rowIndex++) {
148
+ visible.push({ entryId: access.idAt(entryIndex), entryIndex, rowIndex, row: rows[rowIndex] })
149
+ }
150
+ entryIndex = nextNonEmpty(access, entryIndex + 1)
151
+ rowIndex = 0
152
+ if (entryIndex < 0) break
153
+ }
154
+
155
+ const hasOlder = resolved.rowIndex > 0 || previousNonEmpty(access, resolved.entryIndex - 1) >= 0
156
+ const last = visible.at(-1)
157
+ const hasNewer = !!last && (
158
+ last.rowIndex < access.rowsAt(last.entryIndex).length - 1 ||
159
+ nextNonEmpty(access, last.entryIndex + 1) >= 0
160
+ )
161
+ return { anchor: resolved, rows: visible, hasOlder, hasNewer }
162
+ }
package/src/version.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The TUI package version has one source of truth: package.json.
3
+ *
4
+ * Keep all runtime/UI consumers behind this module so a release cannot show
5
+ * a stale hard-coded version while the npm package has already been bumped.
6
+ */
7
+ import { createRequire } from 'node:module'
8
+
9
+ interface TuiPackageMetadata {
10
+ name?: string
11
+ version?: string
12
+ }
13
+
14
+ const packageJson = createRequire(import.meta.url)('../package.json') as TuiPackageMetadata
15
+
16
+ if (!packageJson.version) {
17
+ throw new Error('TUI package.json is missing a version')
18
+ }
19
+
20
+ export const TUI_VERSION = packageJson.version
21
+ export const TUI_PACKAGE_NAME = packageJson.name ?? '@mobius-os/mobius'
@@ -20,8 +20,9 @@ import { Box, Text } from 'ink'
20
20
  import { render } from 'ink-testing-library'
21
21
  import { Screen } from '../src/components/Screen.js'
22
22
  import { Select } from '../src/components/primitives.js'
23
- import { fitTranscript } from '../src/components/Chat.js'
24
- import type { AnyEntry } from '../src/types.js'
23
+ import { entryScreenRows } from '../src/lib/screen-text.js'
24
+ import { viewsForEntry } from '../src/lib/entry-view.js'
25
+ import { createRowAccess, sliceViewport, tailAnchor } from '../src/lib/transcript-viewport.js'
25
26
 
26
27
  const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
27
28
  const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
@@ -36,17 +37,17 @@ const ROWS = 24
36
37
  async function main() {
37
38
  console.log('\n[SCREEN] no-residue picker transitions\n')
38
39
 
39
- // A hidden older entry may contribute only its tail rows when the viewport
40
- // has one spare row. That preview must retain the entry's foreground style;
41
- // rendering it as a bare dimColor string makes a clipped cyan Markdown link
42
- // look gray even though the rest of the message is colored.
43
- const styledEntries: AnyEntry[] = [
40
+ // A partial message is now represented by the same styled ScreenRow as a
41
+ // complete message. Virtual slicing must preserve its ANSI foreground bytes.
42
+ const styledEntries = [
44
43
  { type: 'assistant', uuid: 'styled-old', message: { role: 'assistant', content: [{ type: 'text', text: '[彩色链接](https://example.com)' }] } },
45
44
  { type: 'assistant', uuid: 'styled-new', message: { role: 'assistant', content: [{ type: 'text', text: '最新消息' }] } },
46
45
  ]
47
- const fitted = fitTranscript(styledEntries, 3, 80)
48
- ok(fitted.peekRows.length === 1, 'small viewport exposes one tail row from the hidden message')
49
- ok(fitted.peekRows[0]?.styled.includes('\x1b[') && fitted.peekRows[0]?.styled.includes('彩色链接'), 'partial older row keeps its ANSI foreground styling')
46
+ const rowAccess = createRowAccess(styledEntries, entry => entry.uuid, entry => entryScreenRows(viewsForEntry(entry), 80))
47
+ const viewport = sliceViewport(rowAccess, tailAnchor(rowAccess, 3), 3)
48
+ const partial = viewport.rows.find(item => item.entryId === 'styled-old')?.row
49
+ ok(partial !== undefined, 'small viewport exposes a row from the partial older message')
50
+ ok(!!partial?.styled.includes('\x1b[') && partial.styled.includes('彩色链接'), 'partial older row keeps its ANSI foreground styling')
50
51
 
51
52
  // ── 1. Without Screen, a tall frame overflows the terminal (the bug). ───────
52
53
  const tall = render(
@@ -138,7 +138,7 @@ async function main() {
138
138
  const tailFrame = strip(lastFrame() ?? '')
139
139
 
140
140
  ok(tailFrame.includes('回答 24'), 'latest entry visible at tail (not hidden)')
141
- ok(tailFrame.includes('PageUp'), 'older-records hint offers PageUp (nothing is silently lost)')
141
+ ok(tailFrame.includes('↑ 还有较早内容') && tailFrame.includes('滚轮 3 行'), 'navigation reports older content and the exact wheel step')
142
142
 
143
143
  // ── live resize: refit one dynamic frame, never retain old-width output ──
144
144
  resize(stdout as unknown as NodeJS.WriteStream, 52, 18)
@@ -157,21 +157,20 @@ async function main() {
157
157
  ok(tallAnswers > narrowAnswers, 'larger resize reveals more history in the same viewport')
158
158
  ok((tallFrame.match(/>_ Mobius/g) ?? []).length === 1, 'larger resize still has one dynamic header')
159
159
 
160
- // The "↑ 还有 N 条较早记录" hint must be pinned to the FIRST line below the
161
- // header and span the full width it must not float mid-transcript when the
162
- // viewport has spare rows (regression for real terminals, which bound the
163
- // transcript box height via stdout.isTTY).
160
+ // Navigation is a fixed one-row part of the conversation chrome. It must be
161
+ // the FIRST line below the header and state the exact PageUp/PageDown step.
164
162
  const tallLines = tallFrame.split('\n')
165
- const hintIdx = tallLines.findIndex(l => l.includes('较早记录'))
166
- ok(hintIdx === 1, `older-records hint is the first line under the header (line ${hintIdx}, expected 1)`)
163
+ const hintIdx = tallLines.findIndex(l => l.includes('滚轮 3 行'))
164
+ ok(hintIdx === 1, `navigation is the first line under the header (line ${hintIdx}, expected 1)`)
165
+ ok(/PageUp\/PageDown \d+ 行/.test(tallLines[hintIdx] ?? ''), 'navigation exposes the deterministic page size')
167
166
  const messageRows = tallLines.slice(hintIdx + 1).filter(line => line.trim())
168
- ok(messageRows.length > 0 && messageRows[0].includes(''), 'older message tail is visible immediately after the history hint')
167
+ ok(messageRows.length > 0 && messageRows[0].includes('回答'), 'a real virtualized message row follows navigation without a synthetic peek row')
169
168
 
170
169
  // ── PageUp: viewport scrolls back over history ────────────────────────────
171
170
  stdin.write('\x1b[5~') // PageUp
172
171
  await delay(300)
173
172
  const upFrame = strip(lastFrame() ?? '')
174
- ok(upFrame.includes('PageDown'), 'after PageUp: a PageDown hint appears (scrolled up)')
173
+ ok(upFrame.includes('↓ 较新内容'), 'after PageUp: navigation reports newer content below')
175
174
  ok(!upFrame.includes('回答 24'), 'after PageUp: latest entry paged out of view')
176
175
  ok(/回答 \d+/.test(upFrame), 'after PageUp: an older entry is visible')
177
176
 
@@ -185,7 +184,7 @@ async function main() {
185
184
  stdin.write('\x1b[<64;5;5M') // wheel up = scroll back
186
185
  await delay(300)
187
186
  const wheelUp = strip(lastFrame() ?? '')
188
- ok(wheelUp.includes('PageDown'), 'wheel up: a PageDown hint appears (scrolled back)')
187
+ ok(wheelUp.includes('↓ 较新内容'), 'wheel up: navigation reports newer content below')
189
188
  ok(!wheelUp.includes('回答 24'), 'wheel up: latest entry paged out of view')
190
189
  ok(/回答 \d+/.test(wheelUp), 'wheel up: an older entry is visible')
191
190
 
@@ -199,7 +198,7 @@ async function main() {
199
198
  stdin.write('\x1b[M' + String.fromCharCode(96, 50, 50))
200
199
  await delay(300)
201
200
  const legacyUp = strip(lastFrame() ?? '')
202
- ok(legacyUp.includes('PageDown'), 'legacy wheel up: a PageDown hint appears (scrolled back)')
201
+ ok(legacyUp.includes('↓ 较新内容'), 'legacy wheel up: navigation reports newer content below')
203
202
  ok(!legacyUp.includes('回答 24'), 'legacy wheel up: latest entry paged out of view')
204
203
 
205
204
  stdin.write('\x1b[M' + String.fromCharCode(97, 50, 50)) // wheel down Cb = 0x61
@@ -229,13 +228,15 @@ async function main() {
229
228
  await bootToChat(second.stdin, second.lastFrame)
230
229
  await populateTranscript(second.stdin, emitEntry)
231
230
  const before = strip(second.lastFrame() ?? '')
231
+ const beforeAnswers = before.match(/回答 \d+/g) ?? []
232
232
  ok(before.includes('回答 24'), 'disable-mouse: latest entry visible before wheel')
233
233
 
234
234
  second.stdin.write('\x1b[<64;5;5M') // wheel up — must be ignored
235
235
  await delay(300)
236
236
  const after = strip(second.lastFrame() ?? '')
237
+ const afterAnswers = after.match(/回答 \d+/g) ?? []
237
238
  ok(after.includes('回答 24'), 'disable-mouse: wheel up leaves latest entry in view')
238
- ok(!after.includes('PageDown'), 'disable-mouse: wheel up does NOT scroll (no PageDown hint)')
239
+ ok(JSON.stringify(afterAnswers) === JSON.stringify(beforeAnswers), 'disable-mouse: wheel up leaves the visible row window unchanged')
239
240
  } finally {
240
241
  second.unmount()
241
242
  delete process.env.MOBIUS_TUI_DISABLE_MOUSE
@@ -139,7 +139,7 @@ async function main() {
139
139
  const row1 = lines.findIndex(l => l.includes('回答 1'))
140
140
  const row3 = lines.findIndex(l => l.includes('回答 3'))
141
141
  ok(row1 >= 0 && row3 >= 0, `found 回答 1 (row ${row1}) and 回答 3 (row ${row3}) in the transcript`)
142
- ok(!frame.includes('PageDown'), 'all entries fit — no paging hints at rest')
142
+ ok(frame.includes('全部内容') && !frame.includes('↑ 较早内容') && !frame.includes('↓ 还有较新内容'), 'all entries fit — navigation reports the complete transcript')
143
143
 
144
144
  // press on 回答 1 (col 4 → first content char), drag to 回答 3 (col beyond EOL)
145
145
  stdin.write(`\x1b[<0;5;${row1 + 1}M`) // left-button press (SGR 1-based)
package/tests/ui.test.tsx CHANGED
@@ -22,7 +22,7 @@ import { PrepScreen } from '../src/components/PrepScreen.js'
22
22
  import { Select, TextInput } from '../src/components/primitives.js'
23
23
  import { MobiusClient } from '../src/api.js'
24
24
  import { renderMarkdownLines } from '../src/markdown.js'
25
- import { viewsForEntry, toolLabel } from '../src/lib/entry-view.js'
25
+ import { dedupeUserEntries, viewsForEntry, toolLabel } from '../src/lib/entry-view.js'
26
26
  import { SseConnection } from '../src/sse.js'
27
27
  import type { ReadyState } from '../src/components/PrepScreen.js'
28
28
 
@@ -263,6 +263,17 @@ function testMarkdownCodeRendering() {
263
263
  ok(unlabelled.length === 1 && unlabelled[0].text === 'echo $HOME' && unlabelled[0].code, 'unlabelled code stays plain instead of being guessed as bash')
264
264
  }
265
265
 
266
+ function testFirstUserEntryDedupe() {
267
+ console.log('\n[UI 4b] first user message event deduplication')
268
+ const framed = '上下文注入\n\n## 用户的问题\n\n你好,检查首条消息'
269
+ const entries = [
270
+ { type: 'user', uuid: 'framed-user', message: { role: 'user', content: framed } },
271
+ { type: 'event_msg', uuid: 'plain-user', payload: { type: 'user_message', message: '你好,检查首条消息' } },
272
+ ]
273
+ const deduped = dedupeUserEntries(entries as any)
274
+ ok(deduped.length === 1, 'framed and plain first-turn user events render once')
275
+ }
276
+
266
277
  // ════════════════════════════════════════════════════════════════════════════
267
278
  // TEST 5 — Prep screen renders the project picker when cwd is unbound
268
279
  // ════════════════════════════════════════════════════════════════════════════
@@ -865,6 +876,7 @@ async function main() {
865
876
  await testChat()
866
877
  await testResumedWorkingStatus()
867
878
  testMarkdownCodeRendering()
879
+ testFirstUserEntryDedupe()
868
880
  await testPrepRender()
869
881
  await testSelectViewport()
870
882
  await testProjectPickerEscQuit()
@@ -0,0 +1,83 @@
1
+ import { performance } from 'node:perf_hooks'
2
+ import { coalesceMouseEvents, parseMouseEvents } from '../src/components/primitives.js'
3
+ import {
4
+ createRowAccess, moveAnchorByRows, sliceViewport, tailAnchor,
5
+ type RowAnchor,
6
+ } from '../src/lib/transcript-viewport.js'
7
+
8
+ interface Entry { id: string; rows: number[] }
9
+
10
+ let passed = 0
11
+ let failed = 0
12
+ function ok(condition: boolean, message: string): void {
13
+ if (condition) { passed += 1; console.log(` ✓ ${message}`) }
14
+ else { failed += 1; console.error(` ✗ ${message}`) }
15
+ }
16
+
17
+ function access(entries: Entry[]) {
18
+ return createRowAccess(entries, entry => entry.id, entry => entry.rows)
19
+ }
20
+
21
+ function sameAnchor(actual: RowAnchor | null, entryId: string, rowIndex: number): boolean {
22
+ return actual?.entryId === entryId && actual.rowIndex === rowIndex
23
+ }
24
+
25
+ function main(): void {
26
+ console.log('\n[VIEWPORT] exact row-level transcript navigation\n')
27
+
28
+ const long = access([{ id: 'long', rows: Array.from({ length: 1000 }, (_, i) => i) }])
29
+ const tail = tailAnchor(long, 24)
30
+ ok(sameAnchor(tail, 'long', 976), '1000-row entry tails at row 976 in a 24-row viewport')
31
+ const pageUp = moveAnchorByRows(long, tail, -23)
32
+ ok(sameAnchor(pageUp, 'long', 953), 'PageUp moves exactly 23 rows inside one long entry')
33
+ ok(sameAnchor(moveAnchorByRows(long, pageUp, 23), 'long', 976), 'PageDown exactly reverses PageUp')
34
+
35
+ const mixed = access([
36
+ { id: 'a', rows: [0, 1] },
37
+ { id: 'b', rows: Array.from({ length: 50 }, (_, i) => i) },
38
+ { id: 'c', rows: [0, 1, 2] },
39
+ { id: 'd', rows: Array.from({ length: 1000 }, (_, i) => i) },
40
+ ])
41
+ const crossed = moveAnchorByRows(mixed, { entryId: 'd', entryIndex: 3, rowIndex: 0 }, -4)
42
+ ok(sameAnchor(crossed, 'b', 49), 'row navigation crosses mixed-height entry boundaries exactly')
43
+ const mixedUp = moveAnchorByRows(mixed, tailAnchor(mixed, 24), -777)
44
+ ok(JSON.stringify(moveAnchorByRows(mixed, mixedUp, 777)) === JSON.stringify(tailAnchor(mixed, 24)), 'large mixed-height PageUp/PageDown movement is reversible')
45
+
46
+ const startSlice = sliceViewport(long, { entryId: 'long', entryIndex: 0, rowIndex: 0 }, 24)
47
+ const middleSlice = sliceViewport(long, { entryId: 'long', entryIndex: 0, rowIndex: 500 }, 24)
48
+ const endSlice = sliceViewport(long, tail, 24)
49
+ ok(startSlice.rows[0]?.row === 0, 'long entry start is directly accessible')
50
+ ok(middleSlice.rows[0]?.row === 500, 'long entry middle is directly accessible')
51
+ ok(endSlice.rows.at(-1)?.row === 999, 'long entry end is directly accessible')
52
+ ok(startSlice.rows.length === 24 && middleSlice.rows.length === 24 && endSlice.rows.length === 24, 'only viewport-height rows are materialized in each slice')
53
+
54
+ const resized = access([{ id: 'long', rows: Array.from({ length: 700 }, (_, i) => i) }])
55
+ const restored = sliceViewport(resized, { entryId: 'long', entryIndex: 0, rowIndex: 500 }, 10).anchor
56
+ ok(sameAnchor(restored, 'long', 500), 'resize preserves a historical entry/row anchor')
57
+ ok(sameAnchor(tailAnchor(resized, 10), 'long', 690), 'tail-follow mode recomputes against the resized layout')
58
+
59
+ const appended = access([
60
+ { id: 'long', rows: Array.from({ length: 1000 }, (_, i) => i) },
61
+ { id: 'new', rows: [0, 1, 2] },
62
+ ])
63
+ const held = sliceViewport(appended, pageUp, 24).anchor
64
+ ok(sameAnchor(held, 'long', 953), 'new entries do not move a historical anchor')
65
+ ok(sameAnchor(tailAnchor(appended, 24), 'long', 979), 'tail-follow mode automatically includes newly appended rows')
66
+
67
+ const burst = '\x1b[<64;5;5M'.repeat(5)
68
+ const coalesced = coalesceMouseEvents(parseMouseEvents(burst))
69
+ ok(coalesced.length === 1 && coalesced[0]?.kind === 'wheel' && coalesced[0].delta === 5, 'five wheel events in one input chunk coalesce into one +5 action')
70
+ ok(sameAnchor(moveAnchorByRows(long, tail, -3 * (coalesced[0]?.kind === 'wheel' ? coalesced[0].delta : 0)), 'long', 961), 'five wheel notches move exactly 15 rows')
71
+
72
+ const thousand = access(Array.from({ length: 1000 }, (_, i) => ({ id: `e-${i}`, rows: [i] })))
73
+ for (let i = 0; i < 100; i++) sliceViewport(thousand, moveAnchorByRows(thousand, tailAnchor(thousand, 24), -i), 24)
74
+ const started = performance.now()
75
+ for (let i = 0; i < 1000; i++) sliceViewport(thousand, moveAnchorByRows(thousand, tailAnchor(thousand, 24), -(i % 900)), 24)
76
+ const averageMs = (performance.now() - started) / 1000
77
+ ok(averageMs < 5, `1000-entry viewport navigation averages under 5ms (${averageMs.toFixed(3)}ms)`)
78
+
79
+ console.log(`\n==== VIEWPORT RESULT: ${passed} passed, ${failed} failed ====\n`)
80
+ process.exit(failed === 0 ? 0 : 1)
81
+ }
82
+
83
+ main()