@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.
@@ -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+[. */
@@ -89,6 +192,26 @@ export function parseMouseEvents(input: string): MouseEventInfo[] {
89
192
  return out
90
193
  }
91
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
+
92
215
  /**
93
216
  * Enables terminal mouse tracking for the lifetime of the calling component and
94
217
  * forwards mouse events (wheel + left-button press/motion/release) to the given
@@ -125,7 +248,7 @@ export function useMouseEvents(handlers: {
125
248
  // A single read() chunk may carry several events and a sequence may be
126
249
  // split across chunks, so accumulate and re-scan.
127
250
  buf += String(chunk)
128
- for (const e of parseMouseEvents(buf)) {
251
+ for (const e of coalesceMouseEvents(parseMouseEvents(buf))) {
129
252
  if (e.kind === 'wheel') refs.current.onWheel?.(e.delta)
130
253
  else if (e.kind === 'press') refs.current.onPress?.(e.row, e.col)
131
254
  else if (e.kind === 'motion') refs.current.onMotion?.(e.row, e.col)
@@ -309,11 +432,12 @@ export interface SelectProps {
309
432
  focused?: boolean
310
433
  title?: string
311
434
  maxVisible?: number // cap rendered rows so long lists never overflow the terminal
435
+ initialActive?: number // initial keyboard focus; useful when a create action occupies row 0
312
436
  }
313
437
 
314
438
  export function Select(props: SelectProps) {
315
439
  const mode = props.mode ?? 'single'
316
- const [active, setActive] = useState(0)
440
+ const [active, setActive] = useState(() => Math.max(0, props.initialActive ?? 0))
317
441
  const items = props.items
318
442
  const selectedSet = new Set<string>(mode === 'multi' ? (props.selected as string[]) ?? [] : [])
319
443
  const { stdout } = useStdout()
@@ -326,7 +450,10 @@ export function Select(props: SelectProps) {
326
450
  if (key.upArrow) { setActive(a => (a - 1 + items.length) % items.length); return }
327
451
  if (key.downArrow) { setActive(a => (a + 1) % items.length); return }
328
452
  if (mode === 'single') {
329
- if (key.return) { props.onSelect?.(items[active].value); return }
453
+ if (key.return) {
454
+ props.onSelect?.(items[active].value)
455
+ return
456
+ }
330
457
  } else {
331
458
  if (key.return) { props.onConfirm?.(Array.from(selectedSet)); return }
332
459
  if (input === ' ') { props.onToggle?.(items[active].value); return }
@@ -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 {
@@ -103,8 +102,29 @@ export function headTailLines(text: string, width: number, maxLines: number): st
103
102
  export interface ScreenRows {
104
103
  /** Whether this view renders with a leading blank row (its Box has marginTop). */
105
104
  marginTop: boolean
106
- /** Full screen lines: prefix included, wrapped/truncated, plain text (no ANSI). */
107
- rows: string[]
105
+ /** Full screen rows: a plain layout/copy projection plus its styled ANSI text. */
106
+ rows: ScreenRow[]
107
+ }
108
+
109
+ export type ScreenRowTone =
110
+ | 'normal'
111
+ | 'user'
112
+ | 'tool'
113
+ | 'tool_result'
114
+ | 'tool_error'
115
+ | 'edit_header'
116
+ | 'edit_old'
117
+ | 'edit_new'
118
+ | 'reasoning'
119
+ | 'system'
120
+ | 'error'
121
+
122
+ export interface ScreenRow {
123
+ /** Visible text used for geometry, hit-testing, and clipboard extraction. */
124
+ plain: string
125
+ /** Same row with Markdown/syntax ANSI styling preserved for terminal output. */
126
+ styled: string
127
+ tone: ScreenRowTone
108
128
  }
109
129
 
110
130
  const textWidth = (columns: number) => Math.max(8, columns - 4)
@@ -114,67 +134,87 @@ const fullWidth = (columns: number) => Math.max(1, columns - 2) // root paddingX
114
134
  export function viewScreenRows(view: EntryView, columns: number): ScreenRows {
115
135
  const width = textWidth(columns)
116
136
  const full = fullWidth(columns)
117
- const fit = (rows: string[]): string[] => rows.map((r) => truncateText(r, full))
137
+ const row = (styled: string, tone: ScreenRowTone = 'normal'): ScreenRow => ({
138
+ plain: stripAnsi(styled),
139
+ styled,
140
+ tone,
141
+ })
142
+ // Keep the original styled string and let Ink perform its ANSI-aware
143
+ // truncate-end rendering. The plain projection mirrors the visible row.
144
+ const fit = (rows: string[], tone: ScreenRowTone = 'normal'): ScreenRow[] => rows.map((styled) => ({
145
+ plain: truncateText(stripAnsi(styled), full),
146
+ styled,
147
+ tone,
148
+ }))
149
+ const wrap = (styled: string, tone: ScreenRowTone = 'normal'): ScreenRow[] => (
150
+ wrapAnsi(styled, full, { trim: false, hard: true }).split('\n').map(text => row(text, tone))
151
+ )
118
152
  switch (view.kind) {
119
153
  case 'skip':
120
154
  return { marginTop: false, rows: [] }
121
155
  case 'user': {
122
156
  const rows = view.text.split('\n').map((l, i) => (i === 0 ? `› ${l}` : ` ${l}`))
123
- return { marginTop: true, rows: fit(rows) }
157
+ return { marginTop: true, rows: fit(rows, 'user') }
124
158
  }
125
159
  case 'assistant': {
126
160
  const md = renderMarkdownLines(view.text)
127
- const rows: string[] = []
161
+ const rows: ScreenRow[] = []
128
162
  md.forEach((line, i) => {
129
163
  const prefix = i === 0 ? '• ' : ' '
130
- const fullLine = prefix + stripAnsi(line.text || ' ')
131
- if (line.code) rows.push(truncateText(fullLine, full))
132
- else rows.push(...wrapText(fullLine, full))
164
+ const fullLine = prefix + (line.text || ' ')
165
+ if (line.code) rows.push(...fit([fullLine]))
166
+ else rows.push(...wrap(fullLine))
133
167
  })
134
168
  return { marginTop: true, rows }
135
169
  }
136
170
  case 'tool_call': {
137
171
  const head = clampLines(`${toolLabel(view.toolName)} ${view.summary}`.trim(), width - 2, 1)[0]
138
- const rows = [`• ${head}`]
139
- if (view.result) rows.push(` └ ${clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}`)
140
- return { marginTop: true, rows: fit(rows) }
172
+ const rows = fit([`• ${head}`], 'tool')
173
+ if (view.result) rows.push(...fit(
174
+ [` └ ${clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}`],
175
+ view.result.isError ? 'tool_error' : 'tool_result',
176
+ ))
177
+ return { marginTop: true, rows }
141
178
  }
142
179
  case 'tool_result': {
143
180
  const lines = headTailLines(view.text, width - 4, 5)
144
- return { marginTop: false, rows: fit(lines.map((l, i) => `${i === 0 ? ' └ ' : ' '}${l}`)) }
181
+ return { marginTop: false, rows: fit(
182
+ lines.map((l, i) => `${i === 0 ? ' └ ' : ' '}${l}`),
183
+ view.isError ? 'tool_error' : 'tool_result',
184
+ ) }
145
185
  }
146
186
  case 'code_edit': {
147
- const rows = [`✎ 编辑 ${view.filePath || '(未指定文件)'}`]
148
- if (view.oldString) rows.push(...view.oldString.split('\n').map((l) => ` − ${l}`))
149
- if (view.newString) rows.push(...view.newString.split('\n').map((l) => ` + ${l}`))
150
- return { marginTop: true, rows: fit(rows) }
187
+ const rows = fit([`✎ 编辑 ${view.filePath || '(未指定文件)'}`], 'edit_header')
188
+ if (view.oldString) rows.push(...fit(view.oldString.split('\n').map((l) => ` − ${l}`), 'edit_old'))
189
+ if (view.newString) rows.push(...fit(view.newString.split('\n').map((l) => ` + ${l}`), 'edit_new'))
190
+ return { marginTop: true, rows }
151
191
  }
152
192
  case 'write_file': {
153
- const rows = [`✎ 写入 ${view.filePath || '(未指定文件)'}`]
154
- rows.push(...view.content.split('\n').map((l) => ` + ${l}`))
155
- return { marginTop: true, rows: fit(rows) }
193
+ const rows = fit([`✎ 写入 ${view.filePath || '(未指定文件)'}`], 'edit_header')
194
+ rows.push(...fit(view.content.split('\n').map((l) => ` + ${l}`), 'edit_new'))
195
+ return { marginTop: true, rows }
156
196
  }
157
197
  case 'reasoning': {
158
198
  const lines = clampLines(view.text, width - 4, 2)
159
- return { marginTop: true, rows: fit(lines.map((l, i) => `${i === 0 ? ' ◇ ' : ' '}${l}`)) }
199
+ return { marginTop: true, rows: fit(lines.map((l, i) => `${i === 0 ? ' ◇ ' : ' '}${l}`), 'reasoning') }
160
200
  }
161
201
  case 'system':
162
- return { marginTop: false, rows: fit([` ${clampLines(view.text, width - 2, 2)[0]}`]) }
202
+ return { marginTop: false, rows: fit([` ${clampLines(view.text, width - 2, 2)[0]}`], 'system') }
163
203
  case 'error': {
164
204
  const rows = view.text.split('\n').map((l, i) => `${i === 0 ? '⚠ ' : ' '}${l}`)
165
- return { marginTop: true, rows: fit(rows) }
205
+ return { marginTop: true, rows: fit(rows, 'error') }
166
206
  }
167
207
  default:
168
208
  return { marginTop: false, rows: [] }
169
209
  }
170
210
  }
171
211
 
172
- /** Flatten a whole entry (all its views) into screen lines, margins as ''. */
173
- export function entryScreenLines(views: EntryView[], columns: number): string[] {
174
- const lines: string[] = []
212
+ /** Flatten a whole entry into stable rows, with margins represented explicitly. */
213
+ export function entryScreenRows(views: EntryView[], columns: number): ScreenRow[] {
214
+ const lines: ScreenRow[] = []
175
215
  for (const v of views) {
176
216
  const { marginTop, rows } = viewScreenRows(v, columns)
177
- if (marginTop) lines.push('')
217
+ if (marginTop) lines.push({ plain: '', styled: '', tone: 'normal' })
178
218
  lines.push(...rows)
179
219
  }
180
220
  return lines
@@ -194,30 +234,6 @@ export interface TranscriptGeometry {
194
234
  * (`flexShrink=0`); the middle column holds header + hint + transcript (flexGrow)
195
235
  * + tip + help. Margins that render as extra rows are counted explicitly.
196
236
  */
197
- export function computeTranscriptGeometry(opts: {
198
- viewportRows: number
199
- composerRows: number
200
- statusRows: number
201
- activityRows: number
202
- helpRows: number
203
- showWelcome: boolean
204
- welcomeRows: number
205
- olderHintShown: boolean
206
- tipShown: boolean
207
- }): TranscriptGeometry {
208
- // The composer's reported height already includes its marginTop; the status
209
- // area and working indicator rows are already folded into statusRows and
210
- // activityRows. No extra +1 here — calibrated against the rendered frame.
211
- const bottomH = opts.activityRows + opts.composerRows + opts.statusRows
212
- const midH = opts.viewportRows - bottomH
213
- const headerH = opts.showWelcome ? opts.welcomeRows : 1
214
- const hintH = opts.olderHintShown ? 1 : 0
215
- const tipH = opts.tipShown ? 2 : 0 // marginTop 1 + content 1
216
- const helpH = opts.helpRows > 0 ? opts.helpRows + 1 : 0 // +1 marginTop
217
- const boxTop = headerH + hintH + tipH + helpH
218
- return { boxTop, boxH: Math.max(0, midH - boxTop) }
219
- }
220
-
221
237
  // ── selection mapping ────────────────────────────────────────────────────────
222
238
  export interface SelPoint {
223
239
  entry: number // index into the fitted entries
@@ -230,11 +246,6 @@ export interface TranscriptModel {
230
246
  totalRows: number
231
247
  }
232
248
 
233
- export function buildTranscriptModel(fittedEntries: AnyEntry[], columns: number): TranscriptModel {
234
- const entries = fittedEntries.map((e) => entryScreenLines(viewsForEntry(e), columns))
235
- return { entries, totalRows: entries.reduce((sum, l) => sum + l.length, 0) }
236
- }
237
-
238
249
  /** Convert a screen (row, col) into a SelPoint, or null if outside the transcript. */
239
250
  export function screenToSelPoint(
240
251
  screenRow: number,
@@ -269,7 +280,7 @@ function charAtDisplayWidth(line: string, col: number): number {
269
280
  const w = displayWidth(ch)
270
281
  if (col < acc + w) return i
271
282
  acc += w
272
- i++
283
+ i += ch.length
273
284
  }
274
285
  return i
275
286
  }
@@ -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
+ }