@bojackduy/opencode-telescope 0.1.18 → 0.1.19

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.
@@ -51,7 +51,9 @@ export const ConversationPreview = (props: { item: SearchResult; parts: Conversa
51
51
  <Show when={props.parts.length > 0} fallback={<ConversationFallback item={props.item} syntax={props.syntax} theme={props.theme} />}>
52
52
  <For each={props.parts}>
53
53
  {(part) => (
54
- <PreviewConversationPart part={part} item={props.item} syntax={props.syntax} theme={props.theme} />
54
+ <box id={`preview-part-${part.id}`} flexDirection="column" flexShrink={0}>
55
+ <PreviewConversationPart part={part} item={props.item} syntax={props.syntax} theme={props.theme} />
56
+ </box>
55
57
  )}
56
58
  </For>
57
59
  </Show>
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.18",
4
+ "version": "0.1.19",
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
@@ -95,6 +95,7 @@ let _db: Database | undefined
95
95
  let _dbPath: string | undefined
96
96
  let _indexDb: Database | undefined
97
97
  let _indexDbPath: string | undefined
98
+ const backgroundIndexRebuilds = new Set<string>()
98
99
 
99
100
  function getDb(dbPath?: string): Database {
100
101
  const resolved = dbPath ?? resolveDatabasePath()
@@ -148,8 +149,13 @@ export function searchSessionMessages(query: string, options?: { limit?: number;
148
149
  }
149
150
 
150
151
  export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
151
- const db = getDb(options?.dbPath)
152
- return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset, options?.role).flatMap(
152
+ const dbPath = options?.dbPath ?? resolveDatabasePath()
153
+ const db = getDb(dbPath)
154
+ const limit = options?.limit ?? 40
155
+ const indexed = dbPath === ":memory:" ? undefined : indexedRecentRows(db, dbPath, limit, options?.directory, options?.offset, options?.role)
156
+ if (indexed) return indexed.flatMap((row) => rowToSearchResult(row, "") ?? [])
157
+ debug.log("query:recent:source-fallback", { limit, offset: options?.offset ?? 0, directory: options?.directory, role: options?.role })
158
+ return visibleTextRows(db, limit, undefined, options?.directory, options?.offset, options?.role).flatMap(
153
159
  (row) => rowToSearchResult(row, "") ?? [],
154
160
  )
155
161
  }
@@ -592,6 +598,7 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
592
598
  if (!match) return []
593
599
  try {
594
600
  const index = ensureSearchIndex(db, dbPath)
601
+ if (!index) return []
595
602
  const conditions = ["document_fts MATCH ?"]
596
603
  const params: (string | number)[] = [match]
597
604
  if (role) {
@@ -622,6 +629,46 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
622
629
  }
623
630
  }
624
631
 
632
+ function indexedRecentRows(db: Database, dbPath: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
633
+ try {
634
+ const index = ensureSearchIndex(db, dbPath, { rebuild: false })
635
+ if (!index) {
636
+ scheduleBackgroundIndexRebuild(dbPath)
637
+ return
638
+ }
639
+
640
+ const conditions: string[] = ["part_type = 'text'"]
641
+ const params: (string | number)[] = []
642
+ if (role) {
643
+ conditions.push("role = ?")
644
+ params.push(role)
645
+ }
646
+ if (directory) {
647
+ conditions.push("directory = ?")
648
+ params.push(directory)
649
+ }
650
+ params.push(limit)
651
+ if (offset) params.push(offset)
652
+
653
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
654
+ const offsetClause = offset ? "OFFSET ?" : ""
655
+ debug.time("query:recent:index:exec")
656
+ const rows = index.query<Row, (string | number)[]>(`
657
+ SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
658
+ CAST(time_created AS INTEGER) AS time_created, text
659
+ FROM document_index
660
+ ${where}
661
+ ORDER BY CAST(time_created AS INTEGER) DESC
662
+ LIMIT ? ${offsetClause}
663
+ `).all(...params as any[])
664
+ debug.timeEnd("query:recent:index:exec")
665
+ return rows
666
+ } catch (err) {
667
+ debug.log("recent:index:fallback", err instanceof Error ? err.message : String(err))
668
+ return
669
+ }
670
+ }
671
+
625
672
  function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
626
673
  const offsetClause = offset ? "OFFSET ?" : ""
627
674
  const conditions: string[] = [
@@ -664,7 +711,7 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
664
711
  return rows
665
712
  }
666
713
 
667
- function ensureSearchIndex(source: Database, sourcePath: string) {
714
+ function ensureSearchIndex(source: Database, sourcePath: string, options?: { rebuild?: boolean }) {
668
715
  const indexPath = searchIndexPath(sourcePath)
669
716
  if (!_indexDb || _indexDbPath !== indexPath) {
670
717
  _indexDb?.close()
@@ -679,12 +726,32 @@ function ensureSearchIndex(source: Database, sourcePath: string) {
679
726
  const currentPath = getMeta(_indexDb, "source_path")
680
727
  const currentIndexVersion = getMeta(_indexDb, "index_version")
681
728
  if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
729
+ if (options?.rebuild === false) return
682
730
  rebuildSearchIndex(source, _indexDb, sourcePath, state)
683
731
  }
684
732
  return _indexDb
685
733
  }
686
734
 
687
- const SEARCH_INDEX_VERSION = "4"
735
+ function scheduleBackgroundIndexRebuild(dbPath: string) {
736
+ if (backgroundIndexRebuilds.has(dbPath)) return
737
+ backgroundIndexRebuilds.add(dbPath)
738
+ debug.log("fts:rebuild:background-scheduled", { dbPath })
739
+ const timer = setTimeout(() => {
740
+ debug.time("fts:rebuild:background")
741
+ try {
742
+ const db = getDb(dbPath)
743
+ ensureSearchIndex(db, dbPath)
744
+ } catch (err) {
745
+ debug.log("fts:rebuild:background:error", err instanceof Error ? err.message : String(err))
746
+ } finally {
747
+ backgroundIndexRebuilds.delete(dbPath)
748
+ debug.timeEnd("fts:rebuild:background")
749
+ }
750
+ }, 250)
751
+ ;(timer as { unref?: () => void }).unref?.()
752
+ }
753
+
754
+ const SEARCH_INDEX_VERSION = "6"
688
755
 
689
756
  function searchIndexPath(sourcePath: string) {
690
757
  const parsed = path.parse(sourcePath)
@@ -710,6 +777,24 @@ function migrateSearchIndex(db: Database) {
710
777
  text,
711
778
  tokenize='unicode61'
712
779
  );
780
+ CREATE TABLE IF NOT EXISTS document_index(
781
+ id TEXT PRIMARY KEY,
782
+ message_id TEXT NOT NULL,
783
+ session_id TEXT NOT NULL,
784
+ session_title TEXT NOT NULL,
785
+ directory TEXT NOT NULL,
786
+ role TEXT NOT NULL,
787
+ part_type TEXT NOT NULL,
788
+ tool TEXT,
789
+ time_created INTEGER NOT NULL,
790
+ text TEXT NOT NULL
791
+ );
792
+ CREATE INDEX IF NOT EXISTS document_index_recent_idx
793
+ ON document_index(directory, role, time_created DESC);
794
+ CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
795
+ ON document_index(directory, role, part_type, time_created DESC);
796
+ CREATE INDEX IF NOT EXISTS document_index_time_idx
797
+ ON document_index(time_created DESC);
713
798
  `)
714
799
 
715
800
  const columns = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().map((column) => column.name)
@@ -731,6 +816,33 @@ function migrateSearchIndex(db: Database) {
731
816
  );
732
817
  `)
733
818
  }
819
+
820
+ const indexColumns = db.query<{ name: string }, []>("PRAGMA table_info(document_index)").all().map((column) => column.name)
821
+ if (!["part_type", "tool", "time_created", "text"].every((name) => indexColumns.includes(name))) {
822
+ db.exec("DROP TABLE IF EXISTS document_index")
823
+ db.exec(`
824
+ CREATE TABLE document_index(
825
+ id TEXT PRIMARY KEY,
826
+ message_id TEXT NOT NULL,
827
+ session_id TEXT NOT NULL,
828
+ session_title TEXT NOT NULL,
829
+ directory TEXT NOT NULL,
830
+ role TEXT NOT NULL,
831
+ part_type TEXT NOT NULL,
832
+ tool TEXT,
833
+ time_created INTEGER NOT NULL,
834
+ text TEXT NOT NULL
835
+ );
836
+ `)
837
+ }
838
+ db.exec(`
839
+ CREATE INDEX IF NOT EXISTS document_index_recent_idx
840
+ ON document_index(directory, role, time_created DESC);
841
+ CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
842
+ ON document_index(directory, role, part_type, time_created DESC);
843
+ CREATE INDEX IF NOT EXISTS document_index_time_idx
844
+ ON document_index(time_created DESC);
845
+ `)
734
846
  }
735
847
 
736
848
  function sourceState(db: Database, sourcePath: string) {
@@ -765,9 +877,14 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
765
877
  INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
766
878
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
767
879
  `)
880
+ const insertIndex = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
881
+ INSERT INTO document_index(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
882
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
883
+ `)
768
884
  index.exec("BEGIN IMMEDIATE")
769
885
  try {
770
886
  index.exec("DELETE FROM document_fts")
887
+ index.exec("DELETE FROM document_index")
771
888
  for (const row of rows.flatMap(indexSourceRowToRows)) {
772
889
  insert.run(
773
890
  row.id,
@@ -781,6 +898,18 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
781
898
  row.time_created,
782
899
  row.text,
783
900
  )
901
+ insertIndex.run(
902
+ row.id,
903
+ row.message_id,
904
+ row.session_id,
905
+ row.session_title ?? "Untitled session",
906
+ row.directory,
907
+ row.role,
908
+ row.part_type ?? "text",
909
+ row.tool ?? null,
910
+ row.time_created,
911
+ row.text,
912
+ )
784
913
  }
785
914
  setMeta(index, "source_path", sourcePath)
786
915
  setMeta(index, "source_data_version", String(state.dataVersion))
package/telescope.tsx CHANGED
@@ -20,7 +20,7 @@ import { debug } from "./ui/debug.ts"
20
20
  import { syntaxStyle } from "./ui/format.ts"
21
21
  import type { TelescopeConfig } from "./ui/config.ts"
22
22
  import { inputSafeKeys, keyListLabel, matchesKey, prevent } from "./ui/keyboard.ts"
23
- import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
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
26
  type OwnerFilter = "all" | SearchRole
@@ -46,11 +46,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
46
46
  const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
47
47
  const [prefetchingPreviewBefore, setPrefetchingPreviewBefore] = createSignal(false)
48
48
  const [prefetchingPreviewAfter, setPrefetchingPreviewAfter] = createSignal(false)
49
+ const [previewWindow, setPreviewWindow] = createSignal({ start: 0, end: 0 })
50
+ const [previewHeightVersion, setPreviewHeightVersion] = createSignal(0)
49
51
  const MIN_SEARCH_BATCH_SIZE = 25
50
52
  const MIN_RECENT_BATCH_SIZE = 15
51
53
  const RESULT_OVERSCAN_MULTIPLIER = 2
52
54
  const RESULT_BATCH_VIEWPORTS = 4
53
- const RESULT_PREFETCH_VIEWPORTS = 10
55
+ const RESULT_PREFETCH_VIEWPORTS = 3
54
56
  const RESULT_CACHE_BEHIND_VIEWPORTS = 6
55
57
  const INITIAL_PREVIEW_BEFORE = 20
56
58
  const INITIAL_PREVIEW_AFTER = 30
@@ -87,12 +89,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
87
89
  const directory = props.api.state.path.directory
88
90
  let advanceSelectionAfterLoad = false
89
91
  let advanceSelectionBeforeLoad = false
92
+ let resultNavigationStarted = false
90
93
  let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
91
94
  let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
92
95
  let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
93
96
  let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
94
97
  let pendingPreviewBefore: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
95
98
  let pendingPreviewAfterVisible = false
99
+ const previewMeasuredHeights = new Map<string, number>()
96
100
  const cancelPreviewPrefetch = () => {
97
101
  if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
98
102
  if (previewAfterTimer) clearTimeout(previewAfterTimer)
@@ -177,6 +181,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
177
181
  resultPreviousTimer = undefined
178
182
  advanceSelectionAfterLoad = false
179
183
  advanceSelectionBeforeLoad = false
184
+ resultNavigationStarted = false
180
185
  setLoadingMore(false)
181
186
  setLoadingPreviousResults(false)
182
187
  setPrefetchingResults(false)
@@ -187,9 +192,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
187
192
  setLoading(true)
188
193
  const limit = recentBatchSize()
189
194
  const timer = setTimeout(() => {
195
+ debug.log("bootstrap:recent:start", { limit, directory: dir, role })
190
196
  debug.time("query:recent")
191
197
  try {
192
198
  const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
199
+ debug.log("bootstrap:recent:done", { rows: batch.length, limit })
193
200
  solidBatch(() => {
194
201
  setResults(batch)
195
202
  setResultBaseOffset(0)
@@ -199,6 +206,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
199
206
  setSelected(0)
200
207
  })
201
208
  } catch (err) {
209
+ debug.log("bootstrap:recent:error", err instanceof Error ? err.message : String(err))
202
210
  solidBatch(() => {
203
211
  setResults([])
204
212
  setResultBaseOffset(0)
@@ -220,7 +228,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
220
228
  const timer = setTimeout(() => {
221
229
  debug.time("query:search")
222
230
  try {
231
+ debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q })
223
232
  const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
233
+ debug.log("bootstrap:search:done", { rows: batch.length, limit })
224
234
  solidBatch(() => {
225
235
  setResults(batch)
226
236
  setResultBaseOffset(0)
@@ -370,6 +380,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
370
380
 
371
381
  const move = (delta: number) => {
372
382
  if (results().length === 0) return
383
+ resultNavigationStarted = true
373
384
  setSelected((index) => {
374
385
  const base = resultBaseOffset()
375
386
  const cachedEnd = base + results().length
@@ -409,6 +420,8 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
409
420
  const state = resultPrefetchState()
410
421
  const blockedBy = !hasMore()
411
422
  ? "no-more"
423
+ : !resultNavigationStarted
424
+ ? "waiting-for-navigation"
412
425
  : loadingMore()
413
426
  ? "loading-more"
414
427
  : loadingPreviousResults()
@@ -431,10 +444,76 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
431
444
  onCleanup(() => clearTimeout(timer))
432
445
  })
433
446
 
434
- const previewContentHeight = () => {
435
- return previewScroll?.scrollHeight ?? 0
447
+ const estimatePreviewPartHeight = (part: ConversationPreviewPart) => {
448
+ if (part.type === "tool") {
449
+ if (!part.target) return 2
450
+ if (part.tool === "write") return 40
451
+ if (part.tool === "edit" || part.tool === "apply_patch") return 35
452
+ return 2
453
+ }
454
+ if (part.type === "reasoning") return Math.min(24, Math.max(2, Math.ceil(part.text.length / 120)))
455
+ return Math.min(90, Math.max(3, Math.ceil(part.text.length / 90)))
436
456
  }
437
457
 
458
+ const previewPartHeight = (part: ConversationPreviewPart) => previewMeasuredHeights.get(part.id) ?? estimatePreviewPartHeight(part)
459
+
460
+ const previewVirtualLayout = createMemo(() => {
461
+ previewHeightVersion()
462
+ let offset = 0
463
+ return previewParts().map((part) => {
464
+ const height = previewPartHeight(part)
465
+ const row = { part, top: offset, bottom: offset + height, height }
466
+ offset += height
467
+ return row
468
+ })
469
+ })
470
+
471
+ const previewContentHeight = () => previewVirtualLayout().at(-1)?.bottom ?? 0
472
+
473
+ const updatePreviewWindow = () => {
474
+ const scroll = previewScroll
475
+ const layout = previewVirtualLayout()
476
+ if (layout.length === 0) {
477
+ setPreviewWindow({ start: 0, end: 0 })
478
+ return
479
+ }
480
+ if (!scroll) {
481
+ const next = { start: 0, end: Math.min(layout.length, 20) }
482
+ const current = previewWindow()
483
+ if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
484
+ return
485
+ }
486
+
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)) }
495
+ const current = previewWindow()
496
+ if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
497
+ }
498
+
499
+ const previewWindowParts = createMemo(() => {
500
+ const { start, end } = previewWindow()
501
+ return previewParts().slice(start, end)
502
+ })
503
+
504
+ const previewTopSpacerHeight = createMemo(() => {
505
+ const { start } = previewWindow()
506
+ return previewVirtualLayout()[start]?.top ?? 0
507
+ })
508
+
509
+ const previewBottomSpacerHeight = createMemo(() => {
510
+ const { end } = previewWindow()
511
+ const layout = previewVirtualLayout()
512
+ const total = layout.at(-1)?.bottom ?? 0
513
+ const renderedBottom = end > 0 ? layout[end - 1]?.bottom ?? 0 : 0
514
+ return Math.max(0, total - renderedBottom)
515
+ })
516
+
438
517
  const previewScrollState = () => {
439
518
  const scroll = previewScroll
440
519
  const children = scroll?.getChildren()
@@ -456,6 +535,45 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
456
535
  }
457
536
  }
458
537
 
538
+ const measurePreviewWindow = () => {
539
+ const scroll = previewScroll
540
+ if (!scroll) return
541
+
542
+ let changed = false
543
+ for (const part of previewWindowParts()) {
544
+ const node = findRenderableByID(scroll, `preview-part-${part.id}`)
545
+ const height = node?.height
546
+ if (!height || height <= 0) continue
547
+ if (previewMeasuredHeights.get(part.id) !== height) {
548
+ previewMeasuredHeights.set(part.id, height)
549
+ changed = true
550
+ }
551
+ }
552
+
553
+ if (changed) {
554
+ debug.log("preview:measure", {
555
+ measured: previewWindowParts().length,
556
+ totalMeasured: previewMeasuredHeights.size,
557
+ window: previewWindow(),
558
+ })
559
+ setPreviewHeightVersion((value) => value + 1)
560
+ setTimeout(updatePreviewWindow, 1)
561
+ }
562
+ }
563
+
564
+ createEffect(() => {
565
+ previewParts()
566
+ previewHeightVersion()
567
+ const timer = setTimeout(updatePreviewWindow, 1)
568
+ onCleanup(() => clearTimeout(timer))
569
+ })
570
+
571
+ createEffect(() => {
572
+ previewWindowParts()
573
+ const timer = setTimeout(measurePreviewWindow, 1)
574
+ onCleanup(() => clearTimeout(timer))
575
+ })
576
+
459
577
  const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
460
578
  const item = selectedResult()
461
579
  const first = previewParts()[0]
@@ -498,6 +616,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
498
616
  const beforeAdjust = previewScrollState()
499
617
  const delta = previewContentHeight() - previousContentHeight
500
618
  if (delta > 0) previewScroll?.scrollBy(delta)
619
+ updatePreviewWindow()
501
620
  debug.log("preview:load-before:adjust", {
502
621
  delta,
503
622
  previousContentHeight,
@@ -509,6 +628,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
509
628
  }, 1)
510
629
  } else {
511
630
  setTimeout(() => {
631
+ updatePreviewWindow()
512
632
  debug.log("preview:load-before:no-adjust", {
513
633
  previousContentHeight,
514
634
  newContentHeight: previewContentHeight(),
@@ -555,7 +675,10 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
555
675
  first: page.parts[0]?.id,
556
676
  last: page.parts.at(-1)?.id,
557
677
  })
558
- if (page.parts.length > 0) setPreviewParts((prev) => [...prev, ...page.parts])
678
+ if (page.parts.length > 0) {
679
+ setPreviewParts((prev) => [...prev, ...page.parts])
680
+ setTimeout(updatePreviewWindow, 1)
681
+ }
559
682
  setHasMorePreviewAfter(page.hasMoreAfter)
560
683
  } catch (err) {
561
684
  debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
@@ -631,12 +754,32 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
631
754
  }, 1)
632
755
  }
633
756
 
757
+ const scrollPreviewToEstimatedTarget = (item: SearchResult) => {
758
+ const scroll = previewScroll
759
+ if (!scroll) return
760
+ const targetID = messageTargetID(item)
761
+ const row = previewVirtualLayout().find((entry) => entry.part.id === item.id)
762
+ if (!row) {
763
+ scrollPreviewToTarget(scroll, targetID)
764
+ return
765
+ }
766
+
767
+ const top = Math.max(0, row.top - Math.max(1, Math.floor(scroll.height / 3)))
768
+ debug.log("preview:target-scroll:estimated", { targetID, targetTop: row.top, scrollTop: scroll.scrollTop, nextScrollTop: top, window: previewWindow() })
769
+ scroll.scrollTo(top)
770
+ updatePreviewWindow()
771
+ setTimeout(() => scrollPreviewToTarget(scroll, targetID), 1)
772
+ }
773
+
634
774
  let lastPreviewItemId = ""
635
775
  createEffect(() => {
636
776
  const item = selectedResult()
637
777
  if (!item) {
638
778
  cancelPreviewPrefetch()
639
779
  setPreviewParts([])
780
+ previewMeasuredHeights.clear()
781
+ setPreviewHeightVersion((value) => value + 1)
782
+ setPreviewWindow({ start: 0, end: 0 })
640
783
  setHasMorePreviewBefore(false)
641
784
  setHasMorePreviewAfter(false)
642
785
  return
@@ -644,6 +787,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
644
787
  if (item.id === lastPreviewItemId) return
645
788
  lastPreviewItemId = item.id
646
789
  cancelPreviewPrefetch()
790
+ previewMeasuredHeights.clear()
791
+ setPreviewHeightVersion((value) => value + 1)
792
+ setPreviewWindow({ start: 0, end: 0 })
647
793
  debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
648
794
  const db = dbPath()
649
795
  debug.time("preview:load")
@@ -661,6 +807,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
661
807
  setPreviewParts(page.parts)
662
808
  setHasMorePreviewBefore(page.hasMoreBefore)
663
809
  setHasMorePreviewAfter(page.hasMoreAfter)
810
+ setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
664
811
  } catch {}
665
812
  debug.timeEnd("nav:total")
666
813
  debug.timeEnd("preview:load")
@@ -674,7 +821,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
674
821
  const scroll = previewScroll
675
822
  const children = scroll?.getChildren()
676
823
  if (!scroll || !children || children.length === 0) return
677
- const totalContentHeight = scroll.scrollHeight
824
+ const totalContentHeight = previewContentHeight()
678
825
  const atTop = scroll.scrollTop <= 0
679
826
  const prefetchDistance = Math.max(2, Math.floor(scroll.height * PREVIEW_PREFETCH_VIEWPORTS))
680
827
  const nearTop = scroll.scrollTop <= prefetchDistance
@@ -708,11 +855,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
708
855
  let scrolledItem = ""
709
856
  createEffect(() => {
710
857
  const item = selectedResult()
711
- previewParts()
858
+ previewVirtualLayout()
712
859
  if (!item) return
713
860
  if (item.id === scrolledItem) return
714
861
  scrolledItem = item.id
715
- const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
862
+ const timer = setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
716
863
  onCleanup(() => clearTimeout(timer))
717
864
  })
718
865
 
@@ -742,7 +889,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
742
889
  return
743
890
  }
744
891
 
745
- const totalContentHeight = scroll.scrollHeight
892
+ const totalContentHeight = previewContentHeight()
746
893
  if (direction > 0 && scroll.scrollTop + scroll.height >= totalContentHeight - 1 && hasMorePreviewAfter()) {
747
894
  debug.log("preview:scroll-key:load-after", { direction, totalContentHeight, state: beforeState })
748
895
  schedulePreviewAfter(true)
@@ -751,6 +898,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
751
898
 
752
899
  const amount = direction * previewScrollAmount(scroll)
753
900
  scroll.scrollBy(amount)
901
+ updatePreviewWindow()
754
902
  debug.log("preview:scroll-key:scroll", { direction, amount, before: beforeState, after: previewScrollState() })
755
903
  }
756
904
 
@@ -919,7 +1067,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
919
1067
  <PreviewHeader item={selectedResult()} query={query()} theme={theme()} />
920
1068
  <scrollbox ref={(element: ScrollBoxRenderable) => (previewScroll = element)} flexGrow={1} minHeight={0} paddingLeft={1} paddingRight={1} verticalScrollbarOptions={{ visible: true }}>
921
1069
  <Show when={selectedResult()} fallback={<text fg={theme().textMuted}>Select a hit to preview the exact matched message.</text>}>
922
- {(item) => <ConversationPreview item={item()} parts={previewParts()} syntax={syntax()} theme={theme()} />}
1070
+ {(item) => (
1071
+ <box flexDirection="column" flexShrink={0}>
1072
+ <box height={previewTopSpacerHeight()} flexShrink={0} />
1073
+ <ConversationPreview item={item()} parts={previewWindowParts()} syntax={syntax()} theme={theme()} />
1074
+ <box height={previewBottomSpacerHeight()} flexShrink={0} />
1075
+ </box>
1076
+ )}
923
1077
  </Show>
924
1078
  </scrollbox>
925
1079
  </box>
@@ -59,9 +59,10 @@ export function jumpToRenderedTarget(root: unknown, targetID: string) {
59
59
  setTimeout(tick, 50)
60
60
  }
61
61
 
62
- type RenderNode = {
62
+ export type RenderNode = {
63
63
  id?: string
64
64
  y: number
65
+ height?: number
65
66
  getChildren(): unknown[]
66
67
  }
67
68
 
@@ -94,7 +95,7 @@ function isScrollNode(value: RenderNode): value is ScrollNode {
94
95
  return "scrollBy" in value && typeof value.scrollBy === "function"
95
96
  }
96
97
 
97
- function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
98
+ export function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
98
99
  if (!isRenderNode(node)) return
99
100
  if (node.id === targetID) return node
100
101
  for (const child of node.getChildren()) {