@bojackduy/opencode-telescope 0.1.17 → 0.1.18

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.
@@ -143,8 +143,8 @@ const PreviewToolPart = (props: { part: ConversationPreviewPart; item: SearchRes
143
143
  <Show when={props.part.target}>
144
144
  <TargetMarker part={props.part} item={props.item} role={props.part.tool ?? "tool"} time={props.part.timeCreated} theme={props.theme} />
145
145
  </Show>
146
- <Show when={codeTool()} fallback={<CompactToolRow part={props.part} color={color()} failed={failed()} theme={props.theme} />}>
147
- <CodeToolPreview part={props.part} syntax={props.syntax} theme={props.theme} />
146
+ <Show when={codeTool() && props.part.target} fallback={<CompactToolRow part={props.part} color={color()} failed={failed()} theme={props.theme} />}>
147
+ <CodeToolPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
148
148
  </Show>
149
149
  <Show when={props.part.state?.error}>
150
150
  {(error) => <text fg={props.theme.error}>{error()}</text>}
@@ -167,29 +167,37 @@ const CompactToolRow = (props: { part: ConversationPreviewPart; color: any; fail
167
167
  </>
168
168
  )
169
169
 
170
- const CodeToolPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
171
- if (props.part.tool === "apply_patch") return <ApplyPatchPreview part={props.part} syntax={props.syntax} theme={props.theme} />
172
- if (props.part.tool === "edit") return <EditPreview part={props.part} syntax={props.syntax} theme={props.theme} />
173
- if (props.part.tool === "write") return <WritePreview part={props.part} syntax={props.syntax} theme={props.theme} />
170
+ const CodeToolPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
171
+ if (props.part.tool === "apply_patch") return <ApplyPatchPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
172
+ if (props.part.tool === "edit") return <EditPreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
173
+ if (props.part.tool === "write") return <WritePreview part={props.part} item={props.item} syntax={props.syntax} theme={props.theme} />
174
174
  return <CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />
175
175
  }
176
176
 
177
- const ApplyPatchPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
177
+ const ApplyPatchPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
178
178
  const files = createMemo(() => parseApplyPatchFiles(props.part.state?.metadata))
179
+ const matched = createMemo(() => {
180
+ const query = props.item.match || props.item.previewMatch
181
+ return files().find((file) => containsOrderedTokens(file.patch, query)) ?? files()[0]
182
+ })
179
183
  return (
180
- <Show when={files().length > 0} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
181
- <For each={files()}>
182
- {(file) => (
183
- <ToolBlock title={patchTitle(file)} theme={props.theme}>
184
- <DiffBlock diff={file.patch} filePath={file.filePath} syntax={props.syntax} theme={props.theme} />
184
+ <Show when={matched()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
185
+ {(file) => {
186
+ const view = createMemo(() => clippedText(file().patch, props.item.match || props.item.previewMatch, 24))
187
+ return (
188
+ <ToolBlock title={patchTitle(file())} theme={props.theme}>
189
+ <Show when={view().clipped}>
190
+ <text fg={props.theme.textMuted}>showing matched excerpt from large patch</text>
191
+ </Show>
192
+ <DiffBlock diff={view().text} filePath={file().filePath} syntax={props.syntax} theme={props.theme} clipped={view().clipped} />
185
193
  </ToolBlock>
186
- )}
187
- </For>
194
+ )
195
+ }}
188
196
  </Show>
189
197
  )
190
198
  }
191
199
 
192
- const EditPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
200
+ const EditPreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
193
201
  const input = createMemo(() => recordValue(props.part.state?.input))
194
202
  const metadata = createMemo(() => recordValue(props.part.state?.metadata))
195
203
  const diff = createMemo(() => stringValue(metadata()?.diff) ?? stringValue(recordValue(metadata()?.filediff)?.patch) ?? "")
@@ -198,14 +206,24 @@ const EditPreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle
198
206
  <Show when={diff()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
199
207
  {(value) => (
200
208
  <ToolBlock title={`← Edit ${shortPath(filePath())}`} theme={props.theme}>
201
- <DiffBlock diff={value()} filePath={filePath()} syntax={props.syntax} theme={props.theme} />
209
+ {(() => {
210
+ const view = createMemo(() => clippedText(value(), props.item.match || props.item.previewMatch, 24))
211
+ return (
212
+ <>
213
+ <Show when={view().clipped}>
214
+ <text fg={props.theme.textMuted}>showing matched excerpt from large diff</text>
215
+ </Show>
216
+ <DiffBlock diff={view().text} filePath={filePath()} syntax={props.syntax} theme={props.theme} clipped={view().clipped} />
217
+ </>
218
+ )
219
+ })()}
202
220
  </ToolBlock>
203
221
  )}
204
222
  </Show>
205
223
  )
206
224
  }
207
225
 
208
- const WritePreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
226
+ const WritePreview = (props: { part: ConversationPreviewPart; item: SearchResult; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => {
209
227
  const input = createMemo(() => recordValue(props.part.state?.input))
210
228
  const filePath = createMemo(() => stringValue(input()?.filePath) ?? "")
211
229
  const content = createMemo(() => stringValue(input()?.content) ?? "")
@@ -213,15 +231,25 @@ const WritePreview = (props: { part: ConversationPreviewPart; syntax: SyntaxStyl
213
231
  <Show when={content()} fallback={<CompactToolRow part={props.part} color={props.theme.textMuted} failed={false} theme={props.theme} />}>
214
232
  {(value) => (
215
233
  <ToolBlock title={`# Wrote ${shortPath(filePath())}`} theme={props.theme}>
234
+ {(() => {
235
+ const view = createMemo(() => clippedText(value(), props.item.match || props.item.previewMatch, 32))
236
+ return (
237
+ <>
238
+ <Show when={view().clipped}>
239
+ <text fg={props.theme.textMuted}>showing matched excerpt from large file</text>
240
+ </Show>
216
241
  <line_number fg={props.theme.textMuted} minWidth={3} paddingRight={1}>
217
242
  <code
218
243
  conceal={false}
219
244
  fg={props.theme.text}
220
245
  filetype={filetype(filePath())}
221
246
  syntaxStyle={props.syntax}
222
- content={value()}
247
+ content={view().text}
223
248
  />
224
249
  </line_number>
250
+ </>
251
+ )
252
+ })()}
225
253
  </ToolBlock>
226
254
  )}
227
255
  </Show>
@@ -246,27 +274,29 @@ const ToolBlock = (props: { title: string; children: any; theme: TuiThemeCurrent
246
274
  </box>
247
275
  )
248
276
 
249
- const DiffBlock = (props: { diff: string; filePath: string; syntax: SyntaxStyle; theme: TuiThemeCurrent }) => (
277
+ const DiffBlock = (props: { diff: string; filePath: string; syntax: SyntaxStyle; theme: TuiThemeCurrent; clipped?: boolean }) => (
250
278
  <box paddingLeft={1}>
251
- <diff
252
- diff={props.diff}
253
- view="unified"
254
- filetype={filetype(props.filePath)}
255
- syntaxStyle={props.syntax}
256
- showLineNumbers={true}
257
- width="100%"
258
- wrapMode="word"
259
- fg={props.theme.text}
260
- addedBg={props.theme.diffAddedBg}
261
- removedBg={props.theme.diffRemovedBg}
262
- contextBg={props.theme.diffContextBg}
263
- addedSignColor={props.theme.diffHighlightAdded}
264
- removedSignColor={props.theme.diffHighlightRemoved}
265
- lineNumberFg={props.theme.diffLineNumber}
266
- lineNumberBg={props.theme.diffContextBg}
267
- addedLineNumberBg={props.theme.diffAddedLineNumberBg}
268
- removedLineNumberBg={props.theme.diffRemovedLineNumberBg}
269
- />
279
+ <Show when={!props.clipped} fallback={<code filetype="diff" syntaxStyle={props.syntax} content={props.diff} fg={props.theme.text} />}>
280
+ <diff
281
+ diff={props.diff}
282
+ view="unified"
283
+ filetype={filetype(props.filePath)}
284
+ syntaxStyle={props.syntax}
285
+ showLineNumbers={true}
286
+ width="100%"
287
+ wrapMode="word"
288
+ fg={props.theme.text}
289
+ addedBg={props.theme.diffAddedBg}
290
+ removedBg={props.theme.diffRemovedBg}
291
+ contextBg={props.theme.diffContextBg}
292
+ addedSignColor={props.theme.diffHighlightAdded}
293
+ removedSignColor={props.theme.diffHighlightRemoved}
294
+ lineNumberFg={props.theme.diffLineNumber}
295
+ lineNumberBg={props.theme.diffContextBg}
296
+ addedLineNumberBg={props.theme.diffAddedLineNumberBg}
297
+ removedLineNumberBg={props.theme.diffRemovedLineNumberBg}
298
+ />
299
+ </Show>
270
300
  </box>
271
301
  )
272
302
 
@@ -386,6 +416,50 @@ function matchExcerpt(text: string, query: string, radius = 80) {
386
416
  }
387
417
  }
388
418
 
419
+ function clippedText(text: string, query: string, radiusLines: number) {
420
+ const lines = text.split("\n")
421
+ const tooLarge = text.length > 30000 || lines.length > 420
422
+ if (!tooLarge) return { text, clipped: false }
423
+
424
+ const matchLine = findOrderedTokenLine(lines, query)
425
+ if (matchLine === -1) {
426
+ return { text: lines.slice(0, radiusLines * 2).join("\n"), clipped: true }
427
+ }
428
+
429
+ const start = Math.max(0, matchLine - radiusLines)
430
+ const end = Math.min(lines.length, matchLine + radiusLines + 1)
431
+ return {
432
+ text: [
433
+ start > 0 ? `... ${start} lines omitted ...` : undefined,
434
+ ...lines.slice(start, end),
435
+ end < lines.length ? `... ${lines.length - end} lines omitted ...` : undefined,
436
+ ].filter(Boolean).join("\n"),
437
+ clipped: true,
438
+ }
439
+ }
440
+
441
+ function findOrderedTokenLine(lines: string[], query: string) {
442
+ const tokens = query.trim().split(/\s+/).filter(Boolean)
443
+ if (tokens.length === 0) return -1
444
+ for (let index = 0; index < lines.length; index++) {
445
+ if (containsOrderedTokens(lines[index]!, query)) return index
446
+ }
447
+ return -1
448
+ }
449
+
450
+ function containsOrderedTokens(text: string, query: string) {
451
+ const tokens = query.trim().split(/\s+/).filter(Boolean)
452
+ if (tokens.length === 0) return false
453
+ const lower = text.toLowerCase()
454
+ let searchPos = 0
455
+ for (const token of tokens) {
456
+ const index = lower.indexOf(token.toLowerCase(), searchPos)
457
+ if (index === -1) return false
458
+ searchPos = index + token.length
459
+ }
460
+ return true
461
+ }
462
+
389
463
  function parseApplyPatchFiles(metadata: unknown) {
390
464
  const files = recordValue(metadata)?.files
391
465
  if (!Array.isArray(files)) return []
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-telescope",
4
- "version": "0.1.17",
4
+ "version": "0.1.18",
5
5
  "description": "Fuzzy search across all OpenCode conversations — grep session and chat history, find code snippets, and jump to any chat instantly",
6
6
  "type": "module",
7
7
  "license": "MIT",
package/search.ts CHANGED
@@ -231,7 +231,11 @@ export function loadConversationAround(result: SearchResult, options?: { before?
231
231
  fetchBefore,
232
232
  fetchAfter,
233
233
  beforeRows: beforeRows.length,
234
+ validBefore: validBefore.length,
235
+ invalidBefore: previewRowBreakdown(beforeRows, validBefore),
234
236
  afterRows: afterRows.length,
237
+ validAfter: validAfter.length,
238
+ invalidAfter: previewRowBreakdown(afterRows, validAfter),
235
239
  parts: parts.length,
236
240
  hasMoreBefore: page.hasMoreBefore,
237
241
  hasMoreAfter: page.hasMoreAfter,
@@ -262,6 +266,7 @@ export function loadConversationBefore(result: SearchResult, cursor: Conversatio
262
266
  `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
263
267
  const valid = rows.filter(isPreviewRow)
264
268
  const parts = valid.slice(0, limit).reverse().flatMap((row) => parseConversationPart(row, false) ?? [])
269
+ const parsedIds = new Set(parts.map((part) => part.id))
265
270
  const page = { parts, hasMoreBefore: valid.length > limit }
266
271
  debug.log("preview:window", {
267
272
  item: result.id,
@@ -270,7 +275,10 @@ export function loadConversationBefore(result: SearchResult, cursor: Conversatio
270
275
  cursor,
271
276
  limit,
272
277
  rows: rows.length,
278
+ valid: valid.length,
279
+ invalid: previewRowBreakdown(rows, valid),
273
280
  parts: parts.length,
281
+ unparsedValid: valid.slice(0, limit).filter((row) => !parsedIds.has(row.id)).map((row) => ({ id: row.id, type: row.type, role: row.role })),
274
282
  hasMoreBefore: page.hasMoreBefore,
275
283
  first: parts[0]?.id,
276
284
  last: parts.at(-1)?.id,
@@ -298,6 +306,7 @@ export function loadConversationAfter(result: SearchResult, cursor: Conversation
298
306
  `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
299
307
  const valid = rows.filter(isPreviewRow)
300
308
  const parts = valid.slice(0, limit).flatMap((row) => parseConversationPart(row, false) ?? [])
309
+ const parsedIds = new Set(parts.map((part) => part.id))
301
310
  const page = { parts, hasMoreAfter: valid.length > limit }
302
311
  debug.log("preview:window", {
303
312
  item: result.id,
@@ -306,7 +315,10 @@ export function loadConversationAfter(result: SearchResult, cursor: Conversation
306
315
  cursor,
307
316
  limit,
308
317
  rows: rows.length,
318
+ valid: valid.length,
319
+ invalid: previewRowBreakdown(rows, valid),
309
320
  parts: parts.length,
321
+ unparsedValid: valid.slice(0, limit).filter((row) => !parsedIds.has(row.id)).map((row) => ({ id: row.id, type: row.type, role: row.role })),
310
322
  hasMoreAfter: page.hasMoreAfter,
311
323
  first: parts[0]?.id,
312
324
  last: parts.at(-1)?.id,
@@ -501,7 +513,7 @@ function parseConversationPart(row: ConversationRow, target: boolean): Conversat
501
513
  role: row.role,
502
514
  type: row.type,
503
515
  timeCreated: row.time_created,
504
- text: extractToolIndexText(data).trim(),
516
+ text: target ? extractToolIndexText(data).trim() : "",
505
517
  tool: typeof data.tool === "string" ? data.tool : "tool",
506
518
  state: parseToolState(data.state),
507
519
  target,
@@ -526,6 +538,21 @@ function isPreviewRow(row: ConversationRow) {
526
538
  (row.type === "text" || row.type === "reasoning" || row.type === "tool")
527
539
  }
528
540
 
541
+ function previewRowBreakdown(rows: ConversationRow[], validRows: ConversationRow[]) {
542
+ const valid = new Set(validRows.map((row) => row.id))
543
+ const invalidRows = rows.filter((row) => !valid.has(row.id))
544
+ if (invalidRows.length === 0) return undefined
545
+ const byRole = countBy(invalidRows, (row) => String(row.role ?? "unknown"))
546
+ const byType = countBy(invalidRows, (row) => String(row.type ?? "unknown"))
547
+ return { count: invalidRows.length, byRole, byType }
548
+ }
549
+
550
+ function countBy<T>(items: T[], key: (item: T) => string) {
551
+ const counts: Record<string, number> = {}
552
+ for (const item of items) counts[key(item)] = (counts[key(item)] ?? 0) + 1
553
+ return counts
554
+ }
555
+
529
556
  function parsePartData(data: string) {
530
557
  try {
531
558
  const value = JSON.parse(data) as unknown
package/telescope.tsx CHANGED
@@ -432,18 +432,55 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
432
432
  })
433
433
 
434
434
  const previewContentHeight = () => {
435
- const children = previewScroll?.getChildren()
435
+ return previewScroll?.scrollHeight ?? 0
436
+ }
437
+
438
+ const previewScrollState = () => {
439
+ const scroll = previewScroll
440
+ const children = scroll?.getChildren()
436
441
  const lastChild = children?.[children.length - 1] as { y: number; height: number } | undefined
437
- return lastChild ? lastChild.y + lastChild.height : 0
442
+ return {
443
+ y: scroll?.y,
444
+ height: scroll?.height,
445
+ scrollTop: scroll?.scrollTop,
446
+ scrollHeight: scroll?.scrollHeight,
447
+ childContentHeight: lastChild ? lastChild.y + lastChild.height : 0,
448
+ children: children?.length ?? 0,
449
+ hasMoreBefore: hasMorePreviewBefore(),
450
+ hasMoreAfter: hasMorePreviewAfter(),
451
+ loadingMore: loadingPreviewMore(),
452
+ prefetchingBefore: prefetchingPreviewBefore(),
453
+ prefetchingAfter: prefetchingPreviewAfter(),
454
+ timerBefore: Boolean(previewBeforeTimer),
455
+ timerAfter: Boolean(previewAfterTimer),
456
+ }
438
457
  }
439
458
 
440
459
  const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
441
460
  const item = selectedResult()
442
461
  const first = previewParts()[0]
443
- if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) return
462
+ if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) {
463
+ debug.log("preview:load-before:skip", {
464
+ reason: !item ? "no-item" : !first ? "no-first-part" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
465
+ previousContentHeight,
466
+ preserveScroll,
467
+ visibleLoad,
468
+ state: previewScrollState(),
469
+ })
470
+ return
471
+ }
472
+ const beforeState = previewScrollState()
444
473
  visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
445
474
  debug.time("preview:load-before")
446
475
  try {
476
+ debug.log("preview:load-before:start", {
477
+ item: item.id,
478
+ cursor: { id: first.id, timeCreated: first.timeCreated },
479
+ previousContentHeight,
480
+ preserveScroll,
481
+ visibleLoad,
482
+ state: beforeState,
483
+ })
447
484
  const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
448
485
  debug.log("preview:load-before", {
449
486
  item: item.id,
@@ -458,8 +495,25 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
458
495
  setPreviewParts((prev) => [...page.parts, ...prev])
459
496
  if (preserveScroll) {
460
497
  setTimeout(() => {
498
+ const beforeAdjust = previewScrollState()
461
499
  const delta = previewContentHeight() - previousContentHeight
462
500
  if (delta > 0) previewScroll?.scrollBy(delta)
501
+ debug.log("preview:load-before:adjust", {
502
+ delta,
503
+ previousContentHeight,
504
+ newContentHeight: previewContentHeight(),
505
+ scrolled: delta > 0,
506
+ beforeAdjust,
507
+ afterAdjust: previewScrollState(),
508
+ })
509
+ }, 1)
510
+ } else {
511
+ setTimeout(() => {
512
+ debug.log("preview:load-before:no-adjust", {
513
+ previousContentHeight,
514
+ newContentHeight: previewContentHeight(),
515
+ state: previewScrollState(),
516
+ })
463
517
  }, 1)
464
518
  }
465
519
  }
@@ -475,7 +529,20 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
475
529
  const loadPreviewAfter = (visibleLoad = false) => {
476
530
  const item = selectedResult()
477
531
  const last = previewParts().at(-1)
478
- if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) return
532
+ if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) {
533
+ debug.log("preview:load-after:skip", {
534
+ reason: !item ? "no-item" : !last ? "no-last-part" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-after",
535
+ visibleLoad,
536
+ state: previewScrollState(),
537
+ })
538
+ return
539
+ }
540
+ debug.log("preview:load-after:start", {
541
+ item: item.id,
542
+ cursor: { id: last.id, timeCreated: last.timeCreated },
543
+ visibleLoad,
544
+ state: previewScrollState(),
545
+ })
479
546
  visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewAfter(true)
480
547
  debug.time("preview:load-after")
481
548
  try {
@@ -499,16 +566,40 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
499
566
  }
500
567
 
501
568
  const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
502
- if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) return
569
+ if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) {
570
+ debug.log("preview:prefetch-before:skip", {
571
+ reason: !hasMorePreviewBefore() ? "no-more-before" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
572
+ previousContentHeight,
573
+ preserveScroll,
574
+ visibleLoad,
575
+ state: previewScrollState(),
576
+ })
577
+ return
578
+ }
503
579
  if (previewBeforeTimer) {
504
580
  if (pendingPreviewBefore) {
581
+ const previousPending = { ...pendingPreviewBefore }
505
582
  pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
506
583
  pendingPreviewBefore.visibleLoad = pendingPreviewBefore.visibleLoad || visibleLoad
584
+ debug.log("preview:prefetch-before:merge", {
585
+ previousPending,
586
+ nextPending: pendingPreviewBefore,
587
+ requested: { previousContentHeight, preserveScroll, visibleLoad },
588
+ state: previewScrollState(),
589
+ })
590
+ } else {
591
+ debug.log("preview:prefetch-before:skip", {
592
+ reason: "timer-already-set",
593
+ previousContentHeight,
594
+ preserveScroll,
595
+ visibleLoad,
596
+ state: previewScrollState(),
597
+ })
507
598
  }
508
599
  return
509
600
  }
510
601
  pendingPreviewBefore = { previousContentHeight, preserveScroll, visibleLoad }
511
- debug.log("preview:prefetch-before-scheduled", { preserveScroll, visibleLoad })
602
+ debug.log("preview:prefetch-before-scheduled", { previousContentHeight, preserveScroll, visibleLoad, state: previewScrollState() })
512
603
  previewBeforeTimer = setTimeout(() => {
513
604
  const pending = pendingPreviewBefore
514
605
  previewBeforeTimer = undefined
@@ -518,10 +609,20 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
518
609
  }
519
610
 
520
611
  const schedulePreviewAfter = (visibleLoad = false) => {
521
- if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) return
612
+ if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) {
613
+ debug.log("preview:prefetch-after:skip", {
614
+ reason: !hasMorePreviewAfter() ? "no-more-after" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-after",
615
+ visibleLoad,
616
+ state: previewScrollState(),
617
+ })
618
+ return
619
+ }
522
620
  pendingPreviewAfterVisible = pendingPreviewAfterVisible || visibleLoad
523
- if (previewAfterTimer) return
524
- debug.log("preview:prefetch-after-scheduled", { visibleLoad })
621
+ if (previewAfterTimer) {
622
+ debug.log("preview:prefetch-after:skip", { reason: "timer-already-set", visibleLoad, pendingVisibleLoad: pendingPreviewAfterVisible, state: previewScrollState() })
623
+ return
624
+ }
625
+ debug.log("preview:prefetch-after-scheduled", { visibleLoad, state: previewScrollState() })
525
626
  previewAfterTimer = setTimeout(() => {
526
627
  const pendingVisibleLoad = pendingPreviewAfterVisible
527
628
  previewAfterTimer = undefined
@@ -573,18 +674,19 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
573
674
  const scroll = previewScroll
574
675
  const children = scroll?.getChildren()
575
676
  if (!scroll || !children || children.length === 0) return
576
- const lastChild = children[children.length - 1] as { y: number; height: number }
577
- const totalContentHeight = lastChild.y + lastChild.height
578
- const atTop = scroll.y <= 0
677
+ const totalContentHeight = scroll.scrollHeight
678
+ const atTop = scroll.scrollTop <= 0
579
679
  const prefetchDistance = Math.max(2, Math.floor(scroll.height * PREVIEW_PREFETCH_VIEWPORTS))
580
- const nearTop = scroll.y <= prefetchDistance
581
- const atBottom = scroll.y + scroll.height >= totalContentHeight - 1
582
- const nearBottom = scroll.y + scroll.height >= totalContentHeight - prefetchDistance
680
+ const nearTop = scroll.scrollTop <= prefetchDistance
681
+ const atBottom = scroll.scrollTop + scroll.height >= totalContentHeight - 1
682
+ const nearBottom = scroll.scrollTop + scroll.height >= totalContentHeight - prefetchDistance
583
683
  if (nearTop || nearBottom) {
584
684
  debug.log("preview:scroll-edge", {
585
685
  y: scroll.y,
686
+ scrollTop: scroll.scrollTop,
586
687
  height: scroll.height,
587
688
  contentHeight: totalContentHeight,
689
+ childContentHeight: previewScrollState().childContentHeight,
588
690
  prefetchDistance,
589
691
  atTop,
590
692
  nearTop,
@@ -597,7 +699,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
597
699
  children: children.length,
598
700
  })
599
701
  }
600
- if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, false, true)
702
+ if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, true, false)
601
703
  if (nearBottom && hasMorePreviewAfter()) schedulePreviewAfter(atBottom)
602
704
  }, 400)
603
705
  onCleanup(() => clearInterval(interval))
@@ -625,7 +727,31 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
625
727
 
626
728
  const scrollPreview = (direction: 1 | -1, evt: ParsedKey) => {
627
729
  prevent(evt)
628
- previewScroll?.scrollBy(direction * previewScrollAmount(previewScroll))
730
+ const scroll = previewScroll
731
+ if (!scroll) {
732
+ debug.log("preview:scroll-key:skip", { reason: "no-scroll", direction })
733
+ return
734
+ }
735
+
736
+ const beforeState = previewScrollState()
737
+ debug.log("preview:scroll-key", { direction, state: beforeState })
738
+
739
+ if (direction < 0 && scroll.scrollTop <= 0 && hasMorePreviewBefore()) {
740
+ debug.log("preview:scroll-key:load-before", { direction, state: beforeState })
741
+ schedulePreviewBefore(previewContentHeight(), true, true)
742
+ return
743
+ }
744
+
745
+ const totalContentHeight = scroll.scrollHeight
746
+ if (direction > 0 && scroll.scrollTop + scroll.height >= totalContentHeight - 1 && hasMorePreviewAfter()) {
747
+ debug.log("preview:scroll-key:load-after", { direction, totalContentHeight, state: beforeState })
748
+ schedulePreviewAfter(true)
749
+ return
750
+ }
751
+
752
+ const amount = direction * previewScrollAmount(scroll)
753
+ scroll.scrollBy(amount)
754
+ debug.log("preview:scroll-key:scroll", { direction, amount, before: beforeState, after: previewScrollState() })
629
755
  }
630
756
 
631
757
  const focusInput = () => {
@@ -1,5 +1,6 @@
1
1
  import type { ScrollBoxRenderable } from "@opentui/core"
2
2
  import type { SearchResult } from "../search.ts"
3
+ import { debug } from "./debug.ts"
3
4
 
4
5
  export function previewScrollAmount(scroll: ScrollBoxRenderable | undefined) {
5
6
  return Math.max(1, Math.floor((scroll?.height || 10) / 8))
@@ -13,10 +14,35 @@ export function messageTargetID(item: SearchResult) {
13
14
  }
14
15
 
15
16
  export function scrollPreviewToTarget(scroll: ScrollBoxRenderable | undefined, targetID: string) {
16
- if (!scroll) return
17
+ if (!scroll) {
18
+ debug.log("preview:target-scroll:skip", { reason: "no-scroll", targetID })
19
+ return
20
+ }
17
21
  const target = findRenderableByID(scroll, targetID)
18
- if (!target) return
19
- scroll.scrollBy(target.y - scroll.y - Math.max(1, Math.floor(scroll.height / 3)))
22
+ if (!target) {
23
+ debug.log("preview:target-scroll:skip", {
24
+ reason: "target-not-found",
25
+ targetID,
26
+ y: scroll.y,
27
+ scrollTop: scroll.scrollTop,
28
+ scrollHeight: scroll.scrollHeight,
29
+ height: scroll.height,
30
+ children: scroll.getChildren().length,
31
+ })
32
+ return
33
+ }
34
+ const delta = target.y - scroll.y - Math.max(1, Math.floor(scroll.height / 3))
35
+ debug.log("preview:target-scroll", {
36
+ targetID,
37
+ targetY: target.y,
38
+ scrollY: scroll.y,
39
+ scrollTop: scroll.scrollTop,
40
+ scrollHeight: scroll.height,
41
+ contentHeight: scroll.scrollHeight,
42
+ delta,
43
+ })
44
+ scroll.scrollBy(delta)
45
+ debug.log("preview:target-scroll:after", { targetID, scrollY: scroll.y, scrollTop: scroll.scrollTop })
20
46
  }
21
47
 
22
48
  export function jumpToRenderedTarget(root: unknown, targetID: string) {