@bojackduy/opencode-telescope 0.1.20 → 0.1.22

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
@@ -1,9 +1,18 @@
1
1
  # opencode-telescope
2
2
 
3
- Fuzzy search across all your OpenCode conversations grep through session and chat history, find code snippets, and jump to any chat instantly.
3
+ OpenCode TUI plugin for fuzzy-searching local conversation history, session transcripts, and past AI coding chats.
4
+
5
+ Install the npm package `@bojackduy/opencode-telescope` to grep through OpenCode chat history, find old code snippets, and jump back to any session instantly.
4
6
 
5
7
  > Inspired by [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) — a fuzzy finder for your conversation history.
6
8
 
9
+ ## Links
10
+
11
+ - Documentation: https://bojackduy.github.io/opencode-telescope/
12
+ - npm: https://www.npmjs.com/package/@bojackduy/opencode-telescope
13
+ - GitHub: https://github.com/bojackduy/opencode-telescope
14
+ - Issues: https://github.com/bojackduy/opencode-telescope/issues
15
+
7
16
  ![Demo](./assets/demo.png)
8
17
 
9
18
  ## Use cases
@@ -19,9 +28,12 @@ Fuzzy search across all your OpenCode conversations — grep through session and
19
28
  - **Live preview** — preview the matched conversation result before opening
20
29
  - **Find & jump** — select any result and jump straight to that session
21
30
  - **Neovim Telescope-style UX** — familiar `<leader>f` keybind and `/telescope` command
31
+ - **Local-first search** — reads your OpenCode session database without sending chat history to a remote service
22
32
 
23
33
  ## Installation
24
34
 
35
+ Install from npm as `@bojackduy/opencode-telescope`.
36
+
25
37
  Add the plugin to your `tui.json`:
26
38
 
27
39
  ```jsonc
@@ -88,4 +100,8 @@ Key strings support simple names like `j`, `k`, `down`, `up`, `enter`, `return`,
88
100
 
89
101
  Reads the OpenCode local SQLite session database in read-only mode, parses conversations into searchable text, and opens the selected session through the existing TUI route.
90
102
 
103
+ ## Keywords
104
+
105
+ OpenCode plugin, OpenCode TUI, fuzzy finder, conversation search, chat history search, AI coding session search, LLM history, local session search, Telescope-style picker.
106
+
91
107
  ![Demo animation](./assets/demo.gif)
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-telescope",
4
- "version": "0.1.20",
5
- "description": "Fuzzy search across all OpenCode conversations grep session and chat history, find code snippets, and jump to any chat instantly",
4
+ "version": "0.1.22",
5
+ "description": "OpenCode TUI plugin for fuzzy-searching local conversation history, session transcripts, and past AI coding chats",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/Duyyy123/opencode-telescope.git"
10
+ "url": "git+https://github.com/bojackduy/opencode-telescope.git"
11
11
  },
12
- "homepage": "https://github.com/Duyyy123/opencode-telescope#readme",
12
+ "homepage": "https://bojackduy.github.io/opencode-telescope/",
13
13
  "bugs": {
14
- "url": "https://github.com/Duyyy123/opencode-telescope/issues"
14
+ "url": "https://github.com/bojackduy/opencode-telescope/issues"
15
15
  },
16
16
  "keywords": [
17
17
  "opencode",
@@ -21,11 +21,15 @@
21
21
  "search",
22
22
  "session",
23
23
  "conversation",
24
+ "conversation-search",
24
25
  "history",
26
+ "ai-chat-history",
27
+ "llm-history",
25
28
  "fuzzy",
26
29
  "fuzzy-finder",
27
30
  "grep",
28
31
  "finder",
32
+ "opencode-tui",
29
33
  "neovim",
30
34
  "chat-history"
31
35
  ],
package/telescope.tsx CHANGED
@@ -53,7 +53,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
53
53
  const MIN_RECENT_BATCH_SIZE = 15
54
54
  const RESULT_OVERSCAN_MULTIPLIER = 2
55
55
  const RESULT_BATCH_VIEWPORTS = 4
56
- const RESULT_PREFETCH_VIEWPORTS = 3
56
+ const RESULT_PREFETCH_VIEWPORTS = 5
57
57
  const RESULT_CACHE_BEHIND_VIEWPORTS = 6
58
58
  const INITIAL_PREVIEW_BEFORE = 20
59
59
  const INITIAL_PREVIEW_AFTER = 30
@@ -93,10 +93,26 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
93
93
  let resultNavigationStarted = false
94
94
  let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
95
95
  let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
96
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
97
+ let lastFiredQuery = ""
98
+ const SEARCH_DEBOUNCE_MS = 200
99
+ type PreviewBeforeIntent = "passive-prefetch" | "explicit-scroll"
100
+ type PendingPreviewBefore = {
101
+ id: number
102
+ previousContentHeight: number
103
+ previousScrollTop: number
104
+ requestedAmount: number
105
+ intent: PreviewBeforeIntent
106
+ visibleLoad: boolean
107
+ }
108
+ let previewBeforeLoadID = 0
96
109
  let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
97
110
  let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
98
- let pendingPreviewBefore: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
111
+ let pendingPreviewBefore: PendingPreviewBefore | undefined
99
112
  let pendingPreviewAfterVisible = false
113
+ let previewBeforeAdjusting = false
114
+ let pendingCoalescedExplicit: { requestedAmount: number } | undefined
115
+ let pendingPreviewAnchorCorrection: { anchorPartID: string; visualOffset: number; createdAt: number; expiresAt: number } | undefined
100
116
  const previewMeasuredHeights = new Map<string, number>()
101
117
  const cancelPreviewPrefetch = () => {
102
118
  if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
@@ -105,6 +121,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
105
121
  previewAfterTimer = undefined
106
122
  pendingPreviewBefore = undefined
107
123
  pendingPreviewAfterVisible = false
124
+ previewBeforeAdjusting = false
125
+ pendingCoalescedExplicit = undefined
126
+ pendingPreviewAnchorCorrection = undefined
108
127
  setPrefetchingPreviewBefore(false)
109
128
  setPrefetchingPreviewAfter(false)
110
129
  setLoadingPreviewMore(false)
@@ -151,6 +170,12 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
151
170
  return { start: base + cachedStart, end: base + cachedEnd, items: cached.slice(cachedStart, cachedEnd) }
152
171
  })
153
172
 
173
+ const resultTopSpacerHeight = () =>
174
+ Math.max(0, resultRenderWindow().start - resultBaseOffset()) * resultRowHeight()
175
+
176
+ const resultBottomSpacerHeight = () =>
177
+ Math.max(0, resultBaseOffset() + results().length - resultRenderWindow().end) * resultRowHeight()
178
+
154
179
  let lastResultRenderWindow = ""
155
180
  createEffect(() => {
156
181
  const window = resultRenderWindow()
@@ -171,6 +196,51 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
171
196
  })
172
197
  })
173
198
 
199
+ const executeSearch = (q: string, role: SearchRole | undefined, db: string, dir: string) => {
200
+ lastFiredQuery = q
201
+ const limit = q ? Math.min(searchBatchSize(), 80) : recentBatchSize()
202
+ setLoading(true)
203
+ if (q) setBusy(true)
204
+ debug.time("query:search")
205
+ try {
206
+ debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q || "(all recent)" })
207
+ const batch = q
208
+ ? searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
209
+ : recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
210
+ debug.log("bootstrap:search:done", { rows: batch.length, limit })
211
+ solidBatch(() => {
212
+ setResults(batch)
213
+ setResultBaseOffset(0)
214
+ setNextResultOffset(batch.length)
215
+ setHasMore(batch.length >= limit)
216
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
217
+ setSelected(0)
218
+ })
219
+ } catch (err) {
220
+ debug.log("bootstrap:search:error", err instanceof Error ? err.message : String(err))
221
+ solidBatch(() => {
222
+ setResults([])
223
+ setResultBaseOffset(0)
224
+ setNextResultOffset(0)
225
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
226
+ })
227
+ setError(err instanceof Error ? err.message : String(err))
228
+ } finally {
229
+ debug.timeEnd("query:search")
230
+ setBusy(false)
231
+ setLoading(false)
232
+ }
233
+ }
234
+
235
+ const scheduleSearch = (q: string, role: SearchRole | undefined, db: string, dir: string) => {
236
+ if (searchTimer) clearTimeout(searchTimer)
237
+ if (q === lastFiredQuery && results().length > 0) return
238
+ searchTimer = setTimeout(() => {
239
+ searchTimer = undefined
240
+ executeSearch(q, role, db, dir)
241
+ }, q ? SEARCH_DEBOUNCE_MS : 1)
242
+ }
243
+
174
244
  createEffect(() => {
175
245
  const q = query().trim()
176
246
  const role = ownerRole()
@@ -189,73 +259,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
189
259
  const db = dbPath()
190
260
  const dir = directory
191
261
 
192
- if (!q) {
193
- setLoading(true)
194
- const limit = recentBatchSize()
195
- const timer = setTimeout(() => {
196
- debug.log("bootstrap:recent:start", { limit, directory: dir, role })
197
- debug.time("query:recent")
198
- try {
199
- const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
200
- debug.log("bootstrap:recent:done", { rows: batch.length, limit })
201
- solidBatch(() => {
202
- setResults(batch)
203
- setResultBaseOffset(0)
204
- setNextResultOffset(batch.length)
205
- setHasMore(batch.length >= limit)
206
- setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
207
- setSelected(0)
208
- })
209
- } catch (err) {
210
- debug.log("bootstrap:recent:error", err instanceof Error ? err.message : String(err))
211
- solidBatch(() => {
212
- setResults([])
213
- setResultBaseOffset(0)
214
- setNextResultOffset(0)
215
- setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
216
- })
217
- setError(err instanceof Error ? err.message : String(err))
218
- }
219
- debug.timeEnd("query:recent")
220
- setLoading(false)
221
- debug.log("component:interactive")
222
- }, 1)
223
- onCleanup(() => clearTimeout(timer))
224
- return
225
- }
226
-
227
- setLoading(true)
228
- setBusy(true)
229
- const limit = searchBatchSize()
230
- const timer = setTimeout(() => {
231
- debug.time("query:search")
232
- try {
233
- debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q })
234
- const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
235
- debug.log("bootstrap:search:done", { rows: batch.length, limit })
236
- solidBatch(() => {
237
- setResults(batch)
238
- setResultBaseOffset(0)
239
- setNextResultOffset(batch.length)
240
- setHasMore(batch.length >= limit)
241
- setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
242
- setSelected(0)
243
- })
244
- } catch (err) {
245
- solidBatch(() => {
246
- setResults([])
247
- setResultBaseOffset(0)
248
- setNextResultOffset(0)
249
- setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
250
- })
251
- setError(err instanceof Error ? err.message : String(err))
252
- } finally {
253
- debug.timeEnd("query:search")
254
- setBusy(false)
255
- setLoading(false)
256
- }
257
- }, 180)
258
- onCleanup(() => clearTimeout(timer))
262
+ scheduleSearch(q, role, db, dir)
263
+ onCleanup(() => {
264
+ if (searchTimer) clearTimeout(searchTimer)
265
+ searchTimer = undefined
266
+ })
259
267
  })
260
268
 
261
269
  const loadMoreResults = (advance = false) => {
@@ -446,6 +454,31 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
446
454
  onCleanup(() => clearTimeout(timer))
447
455
  })
448
456
 
457
+ createEffect(() => {
458
+ if (!resultNavigationStarted || !hasMore()) return
459
+ const interval = setInterval(() => {
460
+ if (!hasMore() || loadingMore() || loadingPreviousResults() || prefetchingResults() || busy() || loading()) return
461
+ const scroll = resultScroll
462
+ if (!scroll) return
463
+ const children = scroll.getChildren()
464
+ if (!children || children.length === 0) return
465
+ const lastChild = children[children.length - 1] as { y: number; height: number }
466
+ const contentHeight = lastChild.y + lastChild.height
467
+ const scrollThreshold = Math.max(2, Math.floor(scroll.height * RESULT_PREFETCH_VIEWPORTS))
468
+ if (scroll.scrollTop + scroll.height >= contentHeight - scrollThreshold) {
469
+ debug.log("results:scroll-prefetch", {
470
+ scrollTop: scroll.scrollTop,
471
+ height: scroll.height,
472
+ contentHeight,
473
+ scrollThreshold,
474
+ rowsAhead: resultPrefetchState().rowsAhead,
475
+ })
476
+ scheduleResultPrefetch(false)
477
+ }
478
+ }, 200)
479
+ onCleanup(() => clearInterval(interval))
480
+ })
481
+
449
482
  const estimatePreviewPartHeight = (part: ConversationPreviewPart) => {
450
483
  if (part.type === "tool") {
451
484
  if (!part.target) return 2
@@ -459,15 +492,31 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
459
492
 
460
493
  const previewPartHeight = (part: ConversationPreviewPart) => previewMeasuredHeights.get(part.id) ?? estimatePreviewPartHeight(part)
461
494
 
462
- const previewVirtualLayout = createMemo(() => {
463
- previewHeightVersion()
495
+ const buildPreviewLayout = (parts: ConversationPreviewPart[]) => {
464
496
  let offset = 0
465
- return previewParts().map((part) => {
497
+ return parts.map((part) => {
466
498
  const height = previewPartHeight(part)
467
499
  const row = { part, top: offset, bottom: offset + height, height }
468
500
  offset += height
469
501
  return row
470
502
  })
503
+ }
504
+
505
+ const previewWindowForLayout = (layout: ReturnType<typeof buildPreviewLayout>, scrollTop: number, viewportHeight: number) => {
506
+ if (layout.length === 0) return { start: 0, end: 0 }
507
+ const overscan = Math.max(viewportHeight * 2, 40)
508
+ const from = Math.max(0, scrollTop - overscan)
509
+ const to = scrollTop + viewportHeight + overscan
510
+ const foundStart = layout.findIndex((row) => row.bottom >= from)
511
+ const start = foundStart === -1 ? Math.max(0, layout.length - 1) : Math.max(0, foundStart)
512
+ const foundEnd = layout.findIndex((row) => row.top > to)
513
+ const end = foundEnd === -1 ? layout.length : foundEnd
514
+ return { start, end: Math.min(layout.length, Math.max(start + 1, end)) }
515
+ }
516
+
517
+ const previewVirtualLayout = createMemo(() => {
518
+ previewHeightVersion()
519
+ return buildPreviewLayout(previewParts())
471
520
  })
472
521
 
473
522
  const previewContentHeight = () => previewVirtualLayout().at(-1)?.bottom ?? 0
@@ -486,14 +535,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
486
535
  return
487
536
  }
488
537
 
489
- const overscan = Math.max(scroll.height * 2, 40)
490
- const from = Math.max(0, scroll.scrollTop - overscan)
491
- const to = scroll.scrollTop + scroll.height + overscan
492
- const foundStart = layout.findIndex((row) => row.bottom >= from)
493
- const start = foundStart === -1 ? Math.max(0, layout.length - 1) : Math.max(0, foundStart)
494
- const foundEnd = layout.findIndex((row) => row.top > to)
495
- const end = foundEnd === -1 ? layout.length : foundEnd
496
- const next = { start, end: Math.min(layout.length, Math.max(start + 1, end)) }
538
+ const next = previewWindowForLayout(layout, scroll.scrollTop, scroll.height)
497
539
  const current = previewWindow()
498
540
  if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
499
541
  }
@@ -558,6 +600,29 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
558
600
  totalMeasured: previewMeasuredHeights.size,
559
601
  window: previewWindow(),
560
602
  })
603
+ const anchorCorrection = pendingPreviewAnchorCorrection
604
+ if (anchorCorrection && scroll) {
605
+ const now = Date.now()
606
+ const shouldCorrect = now <= anchorCorrection.expiresAt && lastPreviewScrollKeyMs <= anchorCorrection.createdAt
607
+ if (shouldCorrect) {
608
+ const nextLayout = buildPreviewLayout(previewParts())
609
+ const anchor = nextLayout.find((row) => row.part.id === anchorCorrection.anchorPartID)
610
+ if (anchor) {
611
+ const correctedScrollTop = Math.max(0, anchor.top + anchorCorrection.visualOffset)
612
+ const correctedWindow = previewWindowForLayout(nextLayout, correctedScrollTop, scroll.height)
613
+ setPreviewWindow(correctedWindow)
614
+ scroll.scrollTo(correctedScrollTop)
615
+ debug.log("preview:measure:anchor-correct", {
616
+ anchorPartID: anchorCorrection.anchorPartID,
617
+ visualOffset: anchorCorrection.visualOffset,
618
+ correctedScrollTop,
619
+ window: correctedWindow,
620
+ })
621
+ }
622
+ } else {
623
+ pendingPreviewAnchorCorrection = undefined
624
+ }
625
+ }
561
626
  setPreviewHeightVersion((value) => value + 1)
562
627
  setTimeout(updatePreviewWindow, 1)
563
628
  }
@@ -576,75 +641,166 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
576
641
  onCleanup(() => clearTimeout(timer))
577
642
  })
578
643
 
579
- const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
644
+ const loadPreviewBefore = (pending: PendingPreviewBefore) => {
580
645
  const item = selectedResult()
581
646
  const first = previewParts()[0]
582
647
  if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) {
583
648
  debug.log("preview:load-before:skip", {
649
+ loadID: pending.id,
584
650
  reason: !item ? "no-item" : !first ? "no-first-part" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
585
- previousContentHeight,
586
- preserveScroll,
587
- visibleLoad,
651
+ pending,
588
652
  state: previewScrollState(),
589
653
  })
590
654
  return
591
655
  }
592
656
  const beforeState = previewScrollState()
593
- visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
657
+ const beforeLayout = previewVirtualLayout()
658
+ const scrollTop = previewScroll?.scrollTop ?? pending.previousScrollTop
659
+ const anchor = beforeLayout.find((row) => row.bottom >= scrollTop) ?? beforeLayout[0]
660
+ const anchorPartID = anchor?.part.id ?? first.id
661
+ const offsetInAnchor = anchor ? scrollTop - anchor.top : 0
662
+ pending.visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
594
663
  debug.time("preview:load-before")
595
664
  try {
596
665
  debug.log("preview:load-before:start", {
597
- item: item.id,
666
+ loadID: pending.id,
667
+ intent: pending.intent,
598
668
  cursor: { id: first.id, timeCreated: first.timeCreated },
599
- previousContentHeight,
600
- preserveScroll,
601
- visibleLoad,
669
+ previousContentHeight: pending.previousContentHeight,
670
+ previousScrollTop: pending.previousScrollTop,
671
+ anchorPartID,
672
+ offsetInAnchor,
673
+ requestedAmount: pending.requestedAmount,
674
+ visibleLoad: pending.visibleLoad,
602
675
  state: beforeState,
603
676
  })
604
677
  const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
605
- debug.log("preview:load-before", {
606
- item: item.id,
678
+ debug.log("preview:load-before:query-done", {
679
+ loadID: pending.id,
607
680
  added: page.parts.length,
608
681
  hasMoreBefore: page.hasMoreBefore,
609
- preserveScroll,
610
- visibleLoad,
611
682
  first: page.parts[0]?.id,
612
683
  last: page.parts.at(-1)?.id,
613
684
  })
614
685
  if (page.parts.length > 0) {
615
- setPreviewParts((prev) => [...page.parts, ...prev])
616
- if (preserveScroll) {
617
- setTimeout(() => {
618
- const beforeAdjust = previewScrollState()
619
- const delta = previewContentHeight() - previousContentHeight
620
- if (delta > 0) previewScroll?.scrollBy(delta)
621
- updatePreviewWindow()
622
- debug.log("preview:load-before:adjust", {
623
- delta,
624
- previousContentHeight,
625
- newContentHeight: previewContentHeight(),
626
- scrolled: delta > 0,
627
- beforeAdjust,
628
- afterAdjust: previewScrollState(),
686
+ const currentParts = previewParts()
687
+ const nextParts = [...page.parts, ...currentParts]
688
+ const nextLayout = buildPreviewLayout(nextParts)
689
+ const anchorPredicted = nextLayout.find((row) => row.part.id === anchorPartID)
690
+ const anchorPredictedTop = anchorPredicted?.top ?? 0
691
+ const visualOffset = offsetInAnchor - (pending.intent === "explicit-scroll" ? pending.requestedAmount : 0)
692
+ const targetScrollTop = pending.intent === "explicit-scroll"
693
+ ? Math.max(0, anchorPredictedTop + offsetInAnchor - pending.requestedAmount)
694
+ : anchorPredictedTop + offsetInAnchor
695
+ const nextWindow = previewWindowForLayout(nextLayout, targetScrollTop, previewScroll?.height ?? Math.max(8, height() - 7))
696
+
697
+ solidBatch(() => {
698
+ setPreviewParts(nextParts)
699
+ setPreviewWindow(nextWindow)
700
+ })
701
+
702
+ previewScroll?.scrollTo(targetScrollTop)
703
+ const anchorCorrectionCreatedAt = Date.now()
704
+ pendingPreviewAnchorCorrection = {
705
+ anchorPartID,
706
+ visualOffset,
707
+ createdAt: anchorCorrectionCreatedAt,
708
+ expiresAt: anchorCorrectionCreatedAt + 300,
709
+ }
710
+
711
+ debug.log("preview:load-before:commit", {
712
+ loadID: pending.id,
713
+ anchorPartID,
714
+ anchorPredictedTop,
715
+ offsetInAnchor,
716
+ visualOffset,
717
+ targetScrollTop,
718
+ window: nextWindow,
719
+ intent: pending.intent,
720
+ newParts: page.parts.length,
721
+ totalParts: nextParts.length,
722
+ })
723
+
724
+ previewBeforeAdjusting = true
725
+ setTimeout(() => {
726
+ const newLayout = previewVirtualLayout()
727
+ const anchorNew = newLayout.find(e => e.part.id === anchorPartID)
728
+ const actualAnchorTop = anchorNew?.top ?? anchorPredictedTop
729
+ const drift = actualAnchorTop - anchorPredictedTop
730
+
731
+ if (Math.abs(drift) >= 1 && previewScroll) {
732
+ const correctedScrollTop = targetScrollTop + drift
733
+ const correctedWindow = previewWindowForLayout(newLayout, correctedScrollTop, previewScroll.height)
734
+ setPreviewWindow(correctedWindow)
735
+ previewScroll.scrollTo(correctedScrollTop)
736
+ debug.log("preview:load-before:drift-correct", {
737
+ loadID: pending.id,
738
+ drift,
739
+ anchorPredictedTop,
740
+ actualAnchorTop,
741
+ correctedScrollTop,
742
+ window: correctedWindow,
629
743
  })
630
- }, 1)
631
- } else {
632
- setTimeout(() => {
744
+ } else {
633
745
  updatePreviewWindow()
634
- debug.log("preview:load-before:no-adjust", {
635
- previousContentHeight,
636
- newContentHeight: previewContentHeight(),
637
- state: previewScrollState(),
638
- })
639
- }, 1)
640
- }
746
+ }
747
+
748
+ previewBeforeAdjusting = false
749
+
750
+ const coalesced = pendingCoalescedExplicit
751
+ if (coalesced) {
752
+ pendingCoalescedExplicit = undefined
753
+ const scroll = previewScroll
754
+ if (scroll) {
755
+ debug.log("preview:before:coalesce-replay", {
756
+ requestedAmount: coalesced.requestedAmount,
757
+ state: previewScrollState(),
758
+ })
759
+ schedulePreviewBefore({
760
+ intent: "explicit-scroll",
761
+ visibleLoad: true,
762
+ requestedAmount: coalesced.requestedAmount,
763
+ previousScrollTop: scroll.scrollTop,
764
+ })
765
+ }
766
+ }
767
+
768
+ if (pending.intent === "passive-prefetch") {
769
+ const matchItem = selectedResult()
770
+ const scroll = previewScroll
771
+ if (matchItem && scroll) {
772
+ const layout = previewVirtualLayout()
773
+ const matchRow = layout.find((r) => r.part.id === matchItem.id)
774
+ if (matchRow) {
775
+ const viewportHeight = scroll.height
776
+ const desiredScrollTop = Math.max(0, matchRow.top - Math.floor(viewportHeight / 3))
777
+ const msSinceManual = Date.now() - lastPreviewScrollKeyMs
778
+ if (Math.abs(scroll.scrollTop - desiredScrollTop) >= 2 && msSinceManual > 700) {
779
+ const correctedWindow = previewWindowForLayout(layout, desiredScrollTop, viewportHeight)
780
+ setPreviewWindow(correctedWindow)
781
+ scroll.scrollTo(desiredScrollTop)
782
+ debug.log("preview:load-before:recenter", {
783
+ matchPartID: matchItem.id,
784
+ matchTop: matchRow.top,
785
+ scrollTop: scroll.scrollTop,
786
+ desiredScrollTop,
787
+ viewportHeight,
788
+ window: correctedWindow,
789
+ })
790
+ }
791
+ }
792
+ }
793
+ }
794
+ }, 1)
641
795
  }
642
796
  setHasMorePreviewBefore(page.hasMoreBefore)
643
797
  } catch (err) {
644
798
  debug.log("preview:load-before:error", err instanceof Error ? err.message : String(err))
799
+ previewBeforeAdjusting = false
645
800
  } finally {
646
801
  debug.timeEnd("preview:load-before")
647
- visibleLoad ? setLoadingPreviewMore(false) : setPrefetchingPreviewBefore(false)
802
+ lastBeforeLoadMs = Date.now()
803
+ pending.visibleLoad ? setLoadingPreviewMore(false) : setPrefetchingPreviewBefore(false)
648
804
  }
649
805
  }
650
806
 
@@ -690,46 +846,104 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
690
846
  }
691
847
  }
692
848
 
693
- const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
849
+ const schedulePreviewBefore = (opts: {
850
+ intent: PreviewBeforeIntent
851
+ visibleLoad?: boolean
852
+ previousContentHeight?: number
853
+ requestedAmount?: number
854
+ previousScrollTop?: number
855
+ }) => {
856
+ const id = ++previewBeforeLoadID
857
+ const previousContentHeight = opts.previousContentHeight ?? previewContentHeight()
858
+ const previousScrollTop = opts.previousScrollTop ?? previewScroll?.scrollTop ?? 0
859
+ const requestedAmount = opts.requestedAmount ?? 0
860
+ const visibleLoad = opts.visibleLoad ?? false
861
+ const source = opts.intent === "explicit-scroll" ? "key" : "edge"
694
862
  if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) {
695
- debug.log("preview:prefetch-before:skip", {
696
- reason: !hasMorePreviewBefore() ? "no-more-before" : loadingPreviewMore() ? "loading-preview-more" : "prefetching-before",
863
+ debug.log("preview:before:schedule-skip", {
864
+ loadID: id,
865
+ reason: !hasMorePreviewBefore() ? "no-more-before" : loadingPreviewMore() ? "loading" : "prefetching",
866
+ source,
867
+ intent: opts.intent,
697
868
  previousContentHeight,
698
- preserveScroll,
869
+ previousScrollTop,
870
+ requestedAmount,
699
871
  visibleLoad,
700
872
  state: previewScrollState(),
701
873
  })
702
874
  return
703
875
  }
876
+ if (previewBeforeAdjusting) {
877
+ if (opts.intent === "explicit-scroll") {
878
+ pendingCoalescedExplicit = { requestedAmount }
879
+ debug.log("preview:before:coalesce-explicit", {
880
+ loadID: id,
881
+ requestedAmount,
882
+ state: previewScrollState(),
883
+ })
884
+ } else {
885
+ debug.log("preview:before:schedule-skip", {
886
+ loadID: id,
887
+ reason: "adjusting",
888
+ source,
889
+ intent: opts.intent,
890
+ previousContentHeight,
891
+ previousScrollTop,
892
+ requestedAmount,
893
+ visibleLoad,
894
+ state: previewScrollState(),
895
+ })
896
+ }
897
+ return
898
+ }
704
899
  if (previewBeforeTimer) {
705
900
  if (pendingPreviewBefore) {
706
901
  const previousPending = { ...pendingPreviewBefore }
707
- pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
708
902
  pendingPreviewBefore.visibleLoad = pendingPreviewBefore.visibleLoad || visibleLoad
709
- debug.log("preview:prefetch-before:merge", {
903
+ if (opts.intent === "explicit-scroll" && pendingPreviewBefore.intent === "passive-prefetch") {
904
+ pendingPreviewBefore.intent = "explicit-scroll"
905
+ pendingPreviewBefore.requestedAmount = requestedAmount
906
+ pendingPreviewBefore.previousScrollTop = previousScrollTop
907
+ }
908
+ debug.log("preview:before:schedule-merge", {
909
+ loadID: id,
710
910
  previousPending,
711
911
  nextPending: pendingPreviewBefore,
712
- requested: { previousContentHeight, preserveScroll, visibleLoad },
713
- state: previewScrollState(),
912
+ requested: { source, intent: opts.intent, previousContentHeight, previousScrollTop, requestedAmount, visibleLoad },
714
913
  })
715
914
  } else {
716
- debug.log("preview:prefetch-before:skip", {
915
+ debug.log("preview:before:schedule-skip", {
916
+ loadID: id,
717
917
  reason: "timer-already-set",
918
+ source,
919
+ intent: opts.intent,
718
920
  previousContentHeight,
719
- preserveScroll,
921
+ previousScrollTop,
922
+ requestedAmount,
720
923
  visibleLoad,
721
924
  state: previewScrollState(),
722
925
  })
723
926
  }
724
927
  return
725
928
  }
726
- pendingPreviewBefore = { previousContentHeight, preserveScroll, visibleLoad }
727
- debug.log("preview:prefetch-before-scheduled", { previousContentHeight, preserveScroll, visibleLoad, state: previewScrollState() })
929
+ pendingPreviewBefore = { id, previousContentHeight, previousScrollTop, requestedAmount, intent: opts.intent, visibleLoad }
930
+ debug.log("preview:before:schedule", {
931
+ loadID: id,
932
+ source,
933
+ intent: opts.intent,
934
+ previousContentHeight,
935
+ previousScrollTop,
936
+ requestedAmount,
937
+ visibleLoad,
938
+ firstPart: previewParts()[0]?.id,
939
+ partCount: previewParts().length,
940
+ state: previewScrollState(),
941
+ })
728
942
  previewBeforeTimer = setTimeout(() => {
729
943
  const pending = pendingPreviewBefore
730
944
  previewBeforeTimer = undefined
731
945
  pendingPreviewBefore = undefined
732
- if (pending) loadPreviewBefore(pending.previousContentHeight, pending.preserveScroll, pending.visibleLoad)
946
+ if (pending) loadPreviewBefore(pending)
733
947
  }, 1)
734
948
  }
735
949
 
@@ -770,7 +984,24 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
770
984
  debug.log("preview:target-scroll:estimated", { targetID, targetTop: row.top, scrollTop: scroll.scrollTop, nextScrollTop: top, window: previewWindow() })
771
985
  scroll.scrollTo(top)
772
986
  updatePreviewWindow()
773
- setTimeout(() => scrollPreviewToTarget(scroll, targetID), 1)
987
+ const scrollTopBeforeReal = scroll.scrollTop
988
+ setTimeout(() => {
989
+ scrollPreviewToTarget(scroll, targetID)
990
+ const now = Date.now()
991
+ const adjustmentMs = lastPreviewScrollKeyMs
992
+ if (adjustmentMs > 0 && now - adjustmentMs > 300) {
993
+ const targetTopReal = row.top
994
+ const expectedScrollTop = Math.max(0, targetTopReal - Math.max(1, Math.floor(scroll.height / 3)))
995
+ const drift = scroll.scrollTop - expectedScrollTop
996
+ if (Math.abs(drift) >= 2) {
997
+ const correctedScrollTop = scroll.scrollTop - drift
998
+ const correctedWindow = previewWindowForLayout(previewVirtualLayout(), correctedScrollTop, scroll.height)
999
+ setPreviewWindow(correctedWindow)
1000
+ scroll.scrollTo(correctedScrollTop)
1001
+ debug.log("preview:target-scroll:real-correct", { targetID, drift, scrollTopAfterReal: scroll.scrollTop, correctedScrollTop, window: correctedWindow })
1002
+ }
1003
+ }
1004
+ }, 1)
774
1005
  }
775
1006
 
776
1007
  let lastPreviewItemId = ""
@@ -815,11 +1046,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
815
1046
  debug.timeEnd("preview:load")
816
1047
  })
817
1048
 
1049
+ let lastBeforeLoadMs = 0
1050
+ let lastPreviewScrollKeyMs = 0
818
1051
  createEffect(() => {
819
1052
  const item = selectedResult()
820
1053
  if (!item) return
821
1054
  const interval = setInterval(() => {
822
1055
  if (loadingPreviewMore() || prefetchingPreviewBefore() || prefetchingPreviewAfter()) return
1056
+ if (previewBeforeAdjusting) return
823
1057
  const scroll = previewScroll
824
1058
  const children = scroll?.getChildren()
825
1059
  if (!scroll || !children || children.length === 0) return
@@ -829,26 +1063,28 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
829
1063
  const nearTop = scroll.scrollTop <= prefetchDistance
830
1064
  const atBottom = scroll.scrollTop + scroll.height >= totalContentHeight - 1
831
1065
  const nearBottom = scroll.scrollTop + scroll.height >= totalContentHeight - prefetchDistance
832
- if (nearTop || nearBottom) {
833
- debug.log("preview:scroll-edge", {
834
- y: scroll.y,
835
- scrollTop: scroll.scrollTop,
836
- height: scroll.height,
837
- contentHeight: totalContentHeight,
838
- childContentHeight: previewScrollState().childContentHeight,
839
- prefetchDistance,
840
- atTop,
841
- nearTop,
842
- atBottom,
843
- nearBottom,
844
- hasMoreBefore: hasMorePreviewBefore(),
845
- hasMoreAfter: hasMorePreviewAfter(),
846
- prefetchingBefore: prefetchingPreviewBefore(),
847
- prefetchingAfter: prefetchingPreviewAfter(),
848
- children: children.length,
849
- })
1066
+ const msSinceManualScroll = Date.now() - lastPreviewScrollKeyMs
1067
+ const willLoadBefore = atTop && hasMorePreviewBefore() && (Date.now() - lastBeforeLoadMs) > 100 && msSinceManualScroll > 700
1068
+ debug.log("preview:edge:decision", {
1069
+ scrollTop: scroll.scrollTop,
1070
+ height: scroll.height,
1071
+ contentHeight: totalContentHeight,
1072
+ prefetchDistance,
1073
+ atTop,
1074
+ nearTop,
1075
+ atBottom,
1076
+ nearBottom,
1077
+ hasMoreBefore: hasMorePreviewBefore(),
1078
+ hasMoreAfter: hasMorePreviewAfter(),
1079
+ loadingBefore: loadingPreviewMore(),
1080
+ pendingBefore: Boolean(previewBeforeTimer),
1081
+ willLoadBefore,
1082
+ msSinceLastLoad: Date.now() - lastBeforeLoadMs,
1083
+ msSinceManualScroll,
1084
+ })
1085
+ if (willLoadBefore) {
1086
+ schedulePreviewBefore({ intent: "passive-prefetch", previousContentHeight: totalContentHeight, previousScrollTop: scroll.scrollTop })
850
1087
  }
851
- if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, true, false)
852
1088
  if (nearBottom && hasMorePreviewAfter()) schedulePreviewAfter(atBottom)
853
1089
  }, 400)
854
1090
  onCleanup(() => clearInterval(interval))
@@ -883,11 +1119,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
883
1119
  }
884
1120
 
885
1121
  const beforeState = previewScrollState()
1122
+ lastPreviewScrollKeyMs = Date.now()
886
1123
  debug.log("preview:scroll-key", { direction, state: beforeState })
887
1124
 
1125
+ const requestedAmount = previewScrollAmount(scroll)
1126
+
888
1127
  if (direction < 0 && scroll.scrollTop <= 0 && hasMorePreviewBefore()) {
889
- debug.log("preview:scroll-key:load-before", { direction, state: beforeState })
890
- schedulePreviewBefore(previewContentHeight(), true, true)
1128
+ debug.log("preview:scroll-key:load-before", { direction, requestedAmount, state: beforeState })
1129
+ schedulePreviewBefore({ intent: "explicit-scroll", visibleLoad: true, requestedAmount, previousScrollTop: scroll.scrollTop })
891
1130
  return
892
1131
  }
893
1132
 
@@ -898,7 +1137,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
898
1137
  return
899
1138
  }
900
1139
 
901
- const amount = direction * previewScrollAmount(scroll)
1140
+ const amount = direction * requestedAmount
902
1141
  scroll.scrollBy(amount)
903
1142
  updatePreviewWindow()
904
1143
  debug.log("preview:scroll-key:scroll", { direction, amount, before: beforeState, after: previewScrollState() })
@@ -1027,6 +1266,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
1027
1266
  >
1028
1267
  <Show when={!loading()}>
1029
1268
  <Show when={results().length > 0} fallback={<EmptyState query={query()} owner={ownerLabel()} theme={theme()} />}>
1269
+ <box height={resultTopSpacerHeight()} flexShrink={0} />
1030
1270
  <For each={resultRenderWindow().items}>
1031
1271
  {(item, index) => {
1032
1272
  const absoluteIndex = () => resultRenderWindow().start + index()
@@ -1043,6 +1283,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
1043
1283
  )
1044
1284
  }}
1045
1285
  </For>
1286
+ <box height={resultBottomSpacerHeight()} flexShrink={0} />
1046
1287
  <Show when={loadingMore()}>
1047
1288
  <SkeletonRow theme={theme()} />
1048
1289
  </Show>
@@ -31,18 +31,20 @@ export function scrollPreviewToTarget(scroll: ScrollBoxRenderable | undefined, t
31
31
  })
32
32
  return
33
33
  }
34
- const delta = target.y - scroll.y - Math.max(1, Math.floor(scroll.height / 3))
34
+ const contentY = target.y + scroll.scrollTop - scroll.y
35
+ const desiredScrollTop = Math.max(0, contentY - Math.max(1, Math.floor(scroll.height / 3)))
35
36
  debug.log("preview:target-scroll", {
36
37
  targetID,
37
38
  targetY: target.y,
38
39
  scrollY: scroll.y,
39
40
  scrollTop: scroll.scrollTop,
41
+ contentY,
42
+ desiredScrollTop,
40
43
  scrollHeight: scroll.height,
41
44
  contentHeight: scroll.scrollHeight,
42
- delta,
43
45
  })
44
- scroll.scrollBy(delta)
45
- debug.log("preview:target-scroll:after", { targetID, scrollY: scroll.y, scrollTop: scroll.scrollTop })
46
+ scroll.scrollTo(desiredScrollTop)
47
+ debug.log("preview:target-scroll:after", { targetID, scrollY: scroll.y, scrollTop: scroll.scrollTop, desiredScrollTop })
46
48
  }
47
49
 
48
50
  export function jumpToRenderedTarget(root: unknown, targetID: string) {