@bojackduy/opencode-telescope 0.1.19 → 0.1.21

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/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.19",
4
+ "version": "0.1.21",
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
@@ -631,12 +631,31 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
631
631
 
632
632
  function indexedRecentRows(db: Database, dbPath: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
633
633
  try {
634
- const index = ensureSearchIndex(db, dbPath, { rebuild: false })
634
+ const index = ensureSearchIndex(db, dbPath, { rebuild: false, useStale: true })
635
635
  if (!index) {
636
- scheduleBackgroundIndexRebuild(dbPath)
636
+ debug.log("query:recent:index:missing", { dbPath })
637
637
  return
638
638
  }
639
639
 
640
+ const rowCount = index.query<{ count: number }, []>("SELECT COUNT(*) as count FROM document_index").get()?.count ?? 0
641
+ if (rowCount === 0) {
642
+ debug.log("query:recent:index:empty", { dbPath })
643
+ return
644
+ }
645
+
646
+ const state = sourceState(db, dbPath)
647
+ const currentDataVersion = getMeta(index, "source_data_version")
648
+ const currentMtimeMs = getMeta(index, "source_mtime_ms")
649
+ const currentPath = getMeta(index, "source_path")
650
+ const currentIndexVersion = getMeta(index, "index_version")
651
+ if (currentPath !== dbPath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
652
+ debug.log("query:recent:index:stale", {
653
+ dbPath,
654
+ expectedDataVersion: state.dataVersion,
655
+ actualDataVersion: currentDataVersion,
656
+ })
657
+ }
658
+
640
659
  const conditions: string[] = ["part_type = 'text'"]
641
660
  const params: (string | number)[] = []
642
661
  if (role) {
@@ -711,7 +730,7 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
711
730
  return rows
712
731
  }
713
732
 
714
- function ensureSearchIndex(source: Database, sourcePath: string, options?: { rebuild?: boolean }) {
733
+ function ensureSearchIndex(source: Database, sourcePath: string, options?: { rebuild?: boolean; useStale?: boolean }) {
715
734
  const indexPath = searchIndexPath(sourcePath)
716
735
  if (!_indexDb || _indexDbPath !== indexPath) {
717
736
  _indexDb?.close()
@@ -726,7 +745,10 @@ function ensureSearchIndex(source: Database, sourcePath: string, options?: { reb
726
745
  const currentPath = getMeta(_indexDb, "source_path")
727
746
  const currentIndexVersion = getMeta(_indexDb, "index_version")
728
747
  if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
729
- if (options?.rebuild === false) return
748
+ if (options?.rebuild === false) {
749
+ if (options?.useStale) return _indexDb
750
+ return
751
+ }
730
752
  rebuildSearchIndex(source, _indexDb, sourcePath, state)
731
753
  }
732
754
  return _indexDb
package/telescope.tsx CHANGED
@@ -23,6 +23,7 @@ import { inputSafeKeys, keyListLabel, matchesKey, prevent } from "./ui/keyboard.
23
23
  import { findRenderableByID, jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
24
24
 
25
25
  export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; onClose: () => void }) => {
26
+ debug.log("component:render:start")
26
27
  type OwnerFilter = "all" | SearchRole
27
28
  const dimensions = useTerminalDimensions()
28
29
  const [query, setQuery] = createSignal("")
@@ -52,7 +53,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
52
53
  const MIN_RECENT_BATCH_SIZE = 15
53
54
  const RESULT_OVERSCAN_MULTIPLIER = 2
54
55
  const RESULT_BATCH_VIEWPORTS = 4
55
- const RESULT_PREFETCH_VIEWPORTS = 3
56
+ const RESULT_PREFETCH_VIEWPORTS = 5
56
57
  const RESULT_CACHE_BEHIND_VIEWPORTS = 6
57
58
  const INITIAL_PREVIEW_BEFORE = 20
58
59
  const INITIAL_PREVIEW_AFTER = 30
@@ -92,10 +93,23 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
92
93
  let resultNavigationStarted = false
93
94
  let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
94
95
  let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
96
+ type PreviewBeforeIntent = "passive-prefetch" | "explicit-scroll"
97
+ type PendingPreviewBefore = {
98
+ id: number
99
+ previousContentHeight: number
100
+ previousScrollTop: number
101
+ requestedAmount: number
102
+ intent: PreviewBeforeIntent
103
+ visibleLoad: boolean
104
+ }
105
+ let previewBeforeLoadID = 0
95
106
  let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
96
107
  let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
97
- let pendingPreviewBefore: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
108
+ let pendingPreviewBefore: PendingPreviewBefore | undefined
98
109
  let pendingPreviewAfterVisible = false
110
+ let previewBeforeAdjusting = false
111
+ let pendingCoalescedExplicit: { requestedAmount: number } | undefined
112
+ let pendingPreviewAnchorCorrection: { anchorPartID: string; visualOffset: number; createdAt: number; expiresAt: number } | undefined
99
113
  const previewMeasuredHeights = new Map<string, number>()
100
114
  const cancelPreviewPrefetch = () => {
101
115
  if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
@@ -104,6 +118,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
104
118
  previewAfterTimer = undefined
105
119
  pendingPreviewBefore = undefined
106
120
  pendingPreviewAfterVisible = false
121
+ previewBeforeAdjusting = false
122
+ pendingCoalescedExplicit = undefined
123
+ pendingPreviewAnchorCorrection = undefined
107
124
  setPrefetchingPreviewBefore(false)
108
125
  setPrefetchingPreviewAfter(false)
109
126
  setLoadingPreviewMore(false)
@@ -150,6 +167,12 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
150
167
  return { start: base + cachedStart, end: base + cachedEnd, items: cached.slice(cachedStart, cachedEnd) }
151
168
  })
152
169
 
170
+ const resultTopSpacerHeight = () =>
171
+ Math.max(0, resultRenderWindow().start - resultBaseOffset()) * resultRowHeight()
172
+
173
+ const resultBottomSpacerHeight = () =>
174
+ Math.max(0, resultBaseOffset() + results().length - resultRenderWindow().end) * resultRowHeight()
175
+
153
176
  let lastResultRenderWindow = ""
154
177
  createEffect(() => {
155
178
  const window = resultRenderWindow()
@@ -217,6 +240,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
217
240
  }
218
241
  debug.timeEnd("query:recent")
219
242
  setLoading(false)
243
+ debug.log("component:interactive")
220
244
  }, 1)
221
245
  onCleanup(() => clearTimeout(timer))
222
246
  return
@@ -444,6 +468,31 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
444
468
  onCleanup(() => clearTimeout(timer))
445
469
  })
446
470
 
471
+ createEffect(() => {
472
+ if (!resultNavigationStarted || !hasMore()) return
473
+ const interval = setInterval(() => {
474
+ if (!hasMore() || loadingMore() || loadingPreviousResults() || prefetchingResults() || busy() || loading()) return
475
+ const scroll = resultScroll
476
+ if (!scroll) return
477
+ const children = scroll.getChildren()
478
+ if (!children || children.length === 0) return
479
+ const lastChild = children[children.length - 1] as { y: number; height: number }
480
+ const contentHeight = lastChild.y + lastChild.height
481
+ const scrollThreshold = Math.max(2, Math.floor(scroll.height * RESULT_PREFETCH_VIEWPORTS))
482
+ if (scroll.scrollTop + scroll.height >= contentHeight - scrollThreshold) {
483
+ debug.log("results:scroll-prefetch", {
484
+ scrollTop: scroll.scrollTop,
485
+ height: scroll.height,
486
+ contentHeight,
487
+ scrollThreshold,
488
+ rowsAhead: resultPrefetchState().rowsAhead,
489
+ })
490
+ scheduleResultPrefetch(false)
491
+ }
492
+ }, 200)
493
+ onCleanup(() => clearInterval(interval))
494
+ })
495
+
447
496
  const estimatePreviewPartHeight = (part: ConversationPreviewPart) => {
448
497
  if (part.type === "tool") {
449
498
  if (!part.target) return 2
@@ -457,15 +506,31 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
457
506
 
458
507
  const previewPartHeight = (part: ConversationPreviewPart) => previewMeasuredHeights.get(part.id) ?? estimatePreviewPartHeight(part)
459
508
 
460
- const previewVirtualLayout = createMemo(() => {
461
- previewHeightVersion()
509
+ const buildPreviewLayout = (parts: ConversationPreviewPart[]) => {
462
510
  let offset = 0
463
- return previewParts().map((part) => {
511
+ return parts.map((part) => {
464
512
  const height = previewPartHeight(part)
465
513
  const row = { part, top: offset, bottom: offset + height, height }
466
514
  offset += height
467
515
  return row
468
516
  })
517
+ }
518
+
519
+ const previewWindowForLayout = (layout: ReturnType<typeof buildPreviewLayout>, scrollTop: number, viewportHeight: number) => {
520
+ if (layout.length === 0) return { start: 0, end: 0 }
521
+ const overscan = Math.max(viewportHeight * 2, 40)
522
+ const from = Math.max(0, scrollTop - overscan)
523
+ const to = scrollTop + viewportHeight + overscan
524
+ const foundStart = layout.findIndex((row) => row.bottom >= from)
525
+ const start = foundStart === -1 ? Math.max(0, layout.length - 1) : Math.max(0, foundStart)
526
+ const foundEnd = layout.findIndex((row) => row.top > to)
527
+ const end = foundEnd === -1 ? layout.length : foundEnd
528
+ return { start, end: Math.min(layout.length, Math.max(start + 1, end)) }
529
+ }
530
+
531
+ const previewVirtualLayout = createMemo(() => {
532
+ previewHeightVersion()
533
+ return buildPreviewLayout(previewParts())
469
534
  })
470
535
 
471
536
  const previewContentHeight = () => previewVirtualLayout().at(-1)?.bottom ?? 0
@@ -484,14 +549,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
484
549
  return
485
550
  }
486
551
 
487
- const overscan = Math.max(scroll.height * 2, 40)
488
- const from = Math.max(0, scroll.scrollTop - overscan)
489
- const to = scroll.scrollTop + scroll.height + overscan
490
- const foundStart = layout.findIndex((row) => row.bottom >= from)
491
- const start = foundStart === -1 ? Math.max(0, layout.length - 1) : Math.max(0, foundStart)
492
- const foundEnd = layout.findIndex((row) => row.top > to)
493
- const end = foundEnd === -1 ? layout.length : foundEnd
494
- const next = { start, end: Math.min(layout.length, Math.max(start + 1, end)) }
552
+ const next = previewWindowForLayout(layout, scroll.scrollTop, scroll.height)
495
553
  const current = previewWindow()
496
554
  if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
497
555
  }
@@ -556,6 +614,29 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
556
614
  totalMeasured: previewMeasuredHeights.size,
557
615
  window: previewWindow(),
558
616
  })
617
+ const anchorCorrection = pendingPreviewAnchorCorrection
618
+ if (anchorCorrection && scroll) {
619
+ const now = Date.now()
620
+ const shouldCorrect = now <= anchorCorrection.expiresAt && lastPreviewScrollKeyMs <= anchorCorrection.createdAt
621
+ if (shouldCorrect) {
622
+ const nextLayout = buildPreviewLayout(previewParts())
623
+ const anchor = nextLayout.find((row) => row.part.id === anchorCorrection.anchorPartID)
624
+ if (anchor) {
625
+ const correctedScrollTop = Math.max(0, anchor.top + anchorCorrection.visualOffset)
626
+ const correctedWindow = previewWindowForLayout(nextLayout, correctedScrollTop, scroll.height)
627
+ setPreviewWindow(correctedWindow)
628
+ scroll.scrollTo(correctedScrollTop)
629
+ debug.log("preview:measure:anchor-correct", {
630
+ anchorPartID: anchorCorrection.anchorPartID,
631
+ visualOffset: anchorCorrection.visualOffset,
632
+ correctedScrollTop,
633
+ window: correctedWindow,
634
+ })
635
+ }
636
+ } else {
637
+ pendingPreviewAnchorCorrection = undefined
638
+ }
639
+ }
559
640
  setPreviewHeightVersion((value) => value + 1)
560
641
  setTimeout(updatePreviewWindow, 1)
561
642
  }
@@ -574,75 +655,139 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
574
655
  onCleanup(() => clearTimeout(timer))
575
656
  })
576
657
 
577
- const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
658
+ const loadPreviewBefore = (pending: PendingPreviewBefore) => {
578
659
  const item = selectedResult()
579
660
  const first = previewParts()[0]
580
661
  if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) {
581
662
  debug.log("preview:load-before:skip", {
663
+ loadID: pending.id,
582
664
  reason: !item ? "no-item" : !first ? "no-first-part" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
583
- previousContentHeight,
584
- preserveScroll,
585
- visibleLoad,
665
+ pending,
586
666
  state: previewScrollState(),
587
667
  })
588
668
  return
589
669
  }
590
670
  const beforeState = previewScrollState()
591
- visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
671
+ const beforeLayout = previewVirtualLayout()
672
+ const scrollTop = previewScroll?.scrollTop ?? pending.previousScrollTop
673
+ const anchor = beforeLayout.find((row) => row.bottom >= scrollTop) ?? beforeLayout[0]
674
+ const anchorPartID = anchor?.part.id ?? first.id
675
+ const offsetInAnchor = anchor ? scrollTop - anchor.top : 0
676
+ pending.visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
592
677
  debug.time("preview:load-before")
593
678
  try {
594
679
  debug.log("preview:load-before:start", {
595
- item: item.id,
680
+ loadID: pending.id,
681
+ intent: pending.intent,
596
682
  cursor: { id: first.id, timeCreated: first.timeCreated },
597
- previousContentHeight,
598
- preserveScroll,
599
- visibleLoad,
683
+ previousContentHeight: pending.previousContentHeight,
684
+ previousScrollTop: pending.previousScrollTop,
685
+ anchorPartID,
686
+ offsetInAnchor,
687
+ requestedAmount: pending.requestedAmount,
688
+ visibleLoad: pending.visibleLoad,
600
689
  state: beforeState,
601
690
  })
602
691
  const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
603
- debug.log("preview:load-before", {
604
- item: item.id,
692
+ debug.log("preview:load-before:query-done", {
693
+ loadID: pending.id,
605
694
  added: page.parts.length,
606
695
  hasMoreBefore: page.hasMoreBefore,
607
- preserveScroll,
608
- visibleLoad,
609
696
  first: page.parts[0]?.id,
610
697
  last: page.parts.at(-1)?.id,
611
698
  })
612
699
  if (page.parts.length > 0) {
613
- setPreviewParts((prev) => [...page.parts, ...prev])
614
- if (preserveScroll) {
615
- setTimeout(() => {
616
- const beforeAdjust = previewScrollState()
617
- const delta = previewContentHeight() - previousContentHeight
618
- if (delta > 0) previewScroll?.scrollBy(delta)
619
- updatePreviewWindow()
620
- debug.log("preview:load-before:adjust", {
621
- delta,
622
- previousContentHeight,
623
- newContentHeight: previewContentHeight(),
624
- scrolled: delta > 0,
625
- beforeAdjust,
626
- afterAdjust: previewScrollState(),
700
+ const currentParts = previewParts()
701
+ const nextParts = [...page.parts, ...currentParts]
702
+ const nextLayout = buildPreviewLayout(nextParts)
703
+ const anchorPredicted = nextLayout.find((row) => row.part.id === anchorPartID)
704
+ const anchorPredictedTop = anchorPredicted?.top ?? 0
705
+ const visualOffset = offsetInAnchor - (pending.intent === "explicit-scroll" ? pending.requestedAmount : 0)
706
+ const targetScrollTop = pending.intent === "explicit-scroll"
707
+ ? Math.max(0, anchorPredictedTop + offsetInAnchor - pending.requestedAmount)
708
+ : anchorPredictedTop + offsetInAnchor
709
+ const nextWindow = previewWindowForLayout(nextLayout, targetScrollTop, previewScroll?.height ?? Math.max(8, height() - 7))
710
+
711
+ solidBatch(() => {
712
+ setPreviewParts(nextParts)
713
+ setPreviewWindow(nextWindow)
714
+ })
715
+
716
+ previewScroll?.scrollTo(targetScrollTop)
717
+ const anchorCorrectionCreatedAt = Date.now()
718
+ pendingPreviewAnchorCorrection = {
719
+ anchorPartID,
720
+ visualOffset,
721
+ createdAt: anchorCorrectionCreatedAt,
722
+ expiresAt: anchorCorrectionCreatedAt + 300,
723
+ }
724
+
725
+ debug.log("preview:load-before:commit", {
726
+ loadID: pending.id,
727
+ anchorPartID,
728
+ anchorPredictedTop,
729
+ offsetInAnchor,
730
+ visualOffset,
731
+ targetScrollTop,
732
+ window: nextWindow,
733
+ intent: pending.intent,
734
+ newParts: page.parts.length,
735
+ totalParts: nextParts.length,
736
+ })
737
+
738
+ previewBeforeAdjusting = true
739
+ setTimeout(() => {
740
+ const newLayout = previewVirtualLayout()
741
+ const anchorNew = newLayout.find(e => e.part.id === anchorPartID)
742
+ const actualAnchorTop = anchorNew?.top ?? anchorPredictedTop
743
+ const drift = actualAnchorTop - anchorPredictedTop
744
+
745
+ if (Math.abs(drift) >= 1 && previewScroll) {
746
+ const correctedScrollTop = targetScrollTop + drift
747
+ const correctedWindow = previewWindowForLayout(newLayout, correctedScrollTop, previewScroll.height)
748
+ setPreviewWindow(correctedWindow)
749
+ previewScroll.scrollTo(correctedScrollTop)
750
+ debug.log("preview:load-before:drift-correct", {
751
+ loadID: pending.id,
752
+ drift,
753
+ anchorPredictedTop,
754
+ actualAnchorTop,
755
+ correctedScrollTop,
756
+ window: correctedWindow,
627
757
  })
628
- }, 1)
629
- } else {
630
- setTimeout(() => {
758
+ } else {
631
759
  updatePreviewWindow()
632
- debug.log("preview:load-before:no-adjust", {
633
- previousContentHeight,
634
- newContentHeight: previewContentHeight(),
635
- state: previewScrollState(),
636
- })
637
- }, 1)
638
- }
760
+ }
761
+
762
+ previewBeforeAdjusting = false
763
+
764
+ const coalesced = pendingCoalescedExplicit
765
+ if (coalesced) {
766
+ pendingCoalescedExplicit = undefined
767
+ const scroll = previewScroll
768
+ if (scroll) {
769
+ debug.log("preview:before:coalesce-replay", {
770
+ requestedAmount: coalesced.requestedAmount,
771
+ state: previewScrollState(),
772
+ })
773
+ schedulePreviewBefore({
774
+ intent: "explicit-scroll",
775
+ visibleLoad: true,
776
+ requestedAmount: coalesced.requestedAmount,
777
+ previousScrollTop: scroll.scrollTop,
778
+ })
779
+ }
780
+ }
781
+ }, 1)
639
782
  }
640
783
  setHasMorePreviewBefore(page.hasMoreBefore)
641
784
  } catch (err) {
642
785
  debug.log("preview:load-before:error", err instanceof Error ? err.message : String(err))
786
+ previewBeforeAdjusting = false
643
787
  } finally {
644
788
  debug.timeEnd("preview:load-before")
645
- visibleLoad ? setLoadingPreviewMore(false) : setPrefetchingPreviewBefore(false)
789
+ lastBeforeLoadMs = Date.now()
790
+ pending.visibleLoad ? setLoadingPreviewMore(false) : setPrefetchingPreviewBefore(false)
646
791
  }
647
792
  }
648
793
 
@@ -688,46 +833,104 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
688
833
  }
689
834
  }
690
835
 
691
- const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
836
+ const schedulePreviewBefore = (opts: {
837
+ intent: PreviewBeforeIntent
838
+ visibleLoad?: boolean
839
+ previousContentHeight?: number
840
+ requestedAmount?: number
841
+ previousScrollTop?: number
842
+ }) => {
843
+ const id = ++previewBeforeLoadID
844
+ const previousContentHeight = opts.previousContentHeight ?? previewContentHeight()
845
+ const previousScrollTop = opts.previousScrollTop ?? previewScroll?.scrollTop ?? 0
846
+ const requestedAmount = opts.requestedAmount ?? 0
847
+ const visibleLoad = opts.visibleLoad ?? false
848
+ const source = opts.intent === "explicit-scroll" ? "key" : "edge"
692
849
  if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) {
693
- debug.log("preview:prefetch-before:skip", {
694
- reason: !hasMorePreviewBefore() ? "no-more-before" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
850
+ debug.log("preview:before:schedule-skip", {
851
+ loadID: id,
852
+ reason: !hasMorePreviewBefore() ? "no-more-before" : loadingPreviewMore() ? "loading" : "prefetching",
853
+ source,
854
+ intent: opts.intent,
695
855
  previousContentHeight,
696
- preserveScroll,
856
+ previousScrollTop,
857
+ requestedAmount,
697
858
  visibleLoad,
698
859
  state: previewScrollState(),
699
860
  })
700
861
  return
701
862
  }
863
+ if (previewBeforeAdjusting) {
864
+ if (opts.intent === "explicit-scroll") {
865
+ pendingCoalescedExplicit = { requestedAmount }
866
+ debug.log("preview:before:coalesce-explicit", {
867
+ loadID: id,
868
+ requestedAmount,
869
+ state: previewScrollState(),
870
+ })
871
+ } else {
872
+ debug.log("preview:before:schedule-skip", {
873
+ loadID: id,
874
+ reason: "adjusting",
875
+ source,
876
+ intent: opts.intent,
877
+ previousContentHeight,
878
+ previousScrollTop,
879
+ requestedAmount,
880
+ visibleLoad,
881
+ state: previewScrollState(),
882
+ })
883
+ }
884
+ return
885
+ }
702
886
  if (previewBeforeTimer) {
703
887
  if (pendingPreviewBefore) {
704
888
  const previousPending = { ...pendingPreviewBefore }
705
- pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
706
889
  pendingPreviewBefore.visibleLoad = pendingPreviewBefore.visibleLoad || visibleLoad
707
- debug.log("preview:prefetch-before:merge", {
890
+ if (opts.intent === "explicit-scroll" && pendingPreviewBefore.intent === "passive-prefetch") {
891
+ pendingPreviewBefore.intent = "explicit-scroll"
892
+ pendingPreviewBefore.requestedAmount = requestedAmount
893
+ pendingPreviewBefore.previousScrollTop = previousScrollTop
894
+ }
895
+ debug.log("preview:before:schedule-merge", {
896
+ loadID: id,
708
897
  previousPending,
709
898
  nextPending: pendingPreviewBefore,
710
- requested: { previousContentHeight, preserveScroll, visibleLoad },
711
- state: previewScrollState(),
899
+ requested: { source, intent: opts.intent, previousContentHeight, previousScrollTop, requestedAmount, visibleLoad },
712
900
  })
713
901
  } else {
714
- debug.log("preview:prefetch-before:skip", {
902
+ debug.log("preview:before:schedule-skip", {
903
+ loadID: id,
715
904
  reason: "timer-already-set",
905
+ source,
906
+ intent: opts.intent,
716
907
  previousContentHeight,
717
- preserveScroll,
908
+ previousScrollTop,
909
+ requestedAmount,
718
910
  visibleLoad,
719
911
  state: previewScrollState(),
720
912
  })
721
913
  }
722
914
  return
723
915
  }
724
- pendingPreviewBefore = { previousContentHeight, preserveScroll, visibleLoad }
725
- debug.log("preview:prefetch-before-scheduled", { previousContentHeight, preserveScroll, visibleLoad, state: previewScrollState() })
916
+ pendingPreviewBefore = { id, previousContentHeight, previousScrollTop, requestedAmount, intent: opts.intent, visibleLoad }
917
+ debug.log("preview:before:schedule", {
918
+ loadID: id,
919
+ source,
920
+ intent: opts.intent,
921
+ previousContentHeight,
922
+ previousScrollTop,
923
+ requestedAmount,
924
+ visibleLoad,
925
+ firstPart: previewParts()[0]?.id,
926
+ partCount: previewParts().length,
927
+ state: previewScrollState(),
928
+ })
726
929
  previewBeforeTimer = setTimeout(() => {
727
930
  const pending = pendingPreviewBefore
728
931
  previewBeforeTimer = undefined
729
932
  pendingPreviewBefore = undefined
730
- if (pending) loadPreviewBefore(pending.previousContentHeight, pending.preserveScroll, pending.visibleLoad)
933
+ if (pending) loadPreviewBefore(pending)
731
934
  }, 1)
732
935
  }
733
936
 
@@ -813,11 +1016,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
813
1016
  debug.timeEnd("preview:load")
814
1017
  })
815
1018
 
1019
+ let lastBeforeLoadMs = 0
1020
+ let lastPreviewScrollKeyMs = 0
816
1021
  createEffect(() => {
817
1022
  const item = selectedResult()
818
1023
  if (!item) return
819
1024
  const interval = setInterval(() => {
820
1025
  if (loadingPreviewMore() || prefetchingPreviewBefore() || prefetchingPreviewAfter()) return
1026
+ if (previewBeforeAdjusting) return
821
1027
  const scroll = previewScroll
822
1028
  const children = scroll?.getChildren()
823
1029
  if (!scroll || !children || children.length === 0) return
@@ -827,26 +1033,28 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
827
1033
  const nearTop = scroll.scrollTop <= prefetchDistance
828
1034
  const atBottom = scroll.scrollTop + scroll.height >= totalContentHeight - 1
829
1035
  const nearBottom = scroll.scrollTop + scroll.height >= totalContentHeight - prefetchDistance
830
- if (nearTop || nearBottom) {
831
- debug.log("preview:scroll-edge", {
832
- y: scroll.y,
833
- scrollTop: scroll.scrollTop,
834
- height: scroll.height,
835
- contentHeight: totalContentHeight,
836
- childContentHeight: previewScrollState().childContentHeight,
837
- prefetchDistance,
838
- atTop,
839
- nearTop,
840
- atBottom,
841
- nearBottom,
842
- hasMoreBefore: hasMorePreviewBefore(),
843
- hasMoreAfter: hasMorePreviewAfter(),
844
- prefetchingBefore: prefetchingPreviewBefore(),
845
- prefetchingAfter: prefetchingPreviewAfter(),
846
- children: children.length,
847
- })
1036
+ const msSinceManualScroll = Date.now() - lastPreviewScrollKeyMs
1037
+ const willLoadBefore = atTop && hasMorePreviewBefore() && (Date.now() - lastBeforeLoadMs) > 100 && msSinceManualScroll > 700
1038
+ debug.log("preview:edge:decision", {
1039
+ scrollTop: scroll.scrollTop,
1040
+ height: scroll.height,
1041
+ contentHeight: totalContentHeight,
1042
+ prefetchDistance,
1043
+ atTop,
1044
+ nearTop,
1045
+ atBottom,
1046
+ nearBottom,
1047
+ hasMoreBefore: hasMorePreviewBefore(),
1048
+ hasMoreAfter: hasMorePreviewAfter(),
1049
+ loadingBefore: loadingPreviewMore(),
1050
+ pendingBefore: Boolean(previewBeforeTimer),
1051
+ willLoadBefore,
1052
+ msSinceLastLoad: Date.now() - lastBeforeLoadMs,
1053
+ msSinceManualScroll,
1054
+ })
1055
+ if (willLoadBefore) {
1056
+ schedulePreviewBefore({ intent: "passive-prefetch", previousContentHeight: totalContentHeight, previousScrollTop: scroll.scrollTop })
848
1057
  }
849
- if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, true, false)
850
1058
  if (nearBottom && hasMorePreviewAfter()) schedulePreviewAfter(atBottom)
851
1059
  }, 400)
852
1060
  onCleanup(() => clearInterval(interval))
@@ -881,11 +1089,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
881
1089
  }
882
1090
 
883
1091
  const beforeState = previewScrollState()
1092
+ lastPreviewScrollKeyMs = Date.now()
884
1093
  debug.log("preview:scroll-key", { direction, state: beforeState })
885
1094
 
1095
+ const requestedAmount = previewScrollAmount(scroll)
1096
+
886
1097
  if (direction < 0 && scroll.scrollTop <= 0 && hasMorePreviewBefore()) {
887
- debug.log("preview:scroll-key:load-before", { direction, state: beforeState })
888
- schedulePreviewBefore(previewContentHeight(), true, true)
1098
+ debug.log("preview:scroll-key:load-before", { direction, requestedAmount, state: beforeState })
1099
+ schedulePreviewBefore({ intent: "explicit-scroll", visibleLoad: true, requestedAmount, previousScrollTop: scroll.scrollTop })
889
1100
  return
890
1101
  }
891
1102
 
@@ -896,7 +1107,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
896
1107
  return
897
1108
  }
898
1109
 
899
- const amount = direction * previewScrollAmount(scroll)
1110
+ const amount = direction * requestedAmount
900
1111
  scroll.scrollBy(amount)
901
1112
  updatePreviewWindow()
902
1113
  debug.log("preview:scroll-key:scroll", { direction, amount, before: beforeState, after: previewScrollState() })
@@ -1025,6 +1236,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
1025
1236
  >
1026
1237
  <Show when={!loading()}>
1027
1238
  <Show when={results().length > 0} fallback={<EmptyState query={query()} owner={ownerLabel()} theme={theme()} />}>
1239
+ <box height={resultTopSpacerHeight()} flexShrink={0} />
1028
1240
  <For each={resultRenderWindow().items}>
1029
1241
  {(item, index) => {
1030
1242
  const absoluteIndex = () => resultRenderWindow().start + index()
@@ -1041,6 +1253,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
1041
1253
  )
1042
1254
  }}
1043
1255
  </For>
1256
+ <box height={resultBottomSpacerHeight()} flexShrink={0} />
1044
1257
  <Show when={loadingMore()}>
1045
1258
  <SkeletonRow theme={theme()} />
1046
1259
  </Show>
package/tui.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
3
  import { Telescope } from "./telescope.tsx"
4
+ import { debug } from "./ui/debug.ts"
4
5
  import { loadTelescopeConfig } from "./ui/config.ts"
5
6
 
6
7
  const id = "opencode-telescope"
@@ -12,15 +13,19 @@ const enabled = (options: unknown) => {
12
13
  }
13
14
 
14
15
  const tui: TuiPlugin = async (api: TuiPluginApi, options: unknown) => {
16
+ debug.log("plugin:setup:start")
15
17
  if (!enabled(options)) return
16
18
 
17
19
  const config = loadTelescopeConfig()
18
20
  const command = "opencode.telescope.sessions"
19
21
  const open = () => {
22
+ debug.log("plugin:dialog:open:start")
20
23
  api.ui.dialog.replace(() => <Telescope api={api} config={config} onClose={() => api.ui.dialog.clear()} />)
21
24
  api.ui.dialog.setSize("xlarge")
25
+ debug.log("plugin:dialog:open:done")
22
26
  }
23
27
 
28
+ debug.log("plugin:setup:register-keymap")
24
29
  const unregisterKeymap = api.keymap.registerLayer({
25
30
  commands: [
26
31
  {
@@ -38,6 +43,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi, options: unknown) => {
38
43
  api.lifecycle.onDispose(() => {
39
44
  unregisterKeymap()
40
45
  })
46
+ debug.log("plugin:setup:done")
41
47
  }
42
48
 
43
49
  const plugin: TuiPluginModule & { id: string } = {