@bojackduy/opencode-telescope 0.1.18 → 0.1.20

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.20",
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,65 @@ 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, useStale: true })
635
+ if (!index) {
636
+ debug.log("query:recent:index:missing", { dbPath })
637
+ return
638
+ }
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
+
659
+ const conditions: string[] = ["part_type = 'text'"]
660
+ const params: (string | number)[] = []
661
+ if (role) {
662
+ conditions.push("role = ?")
663
+ params.push(role)
664
+ }
665
+ if (directory) {
666
+ conditions.push("directory = ?")
667
+ params.push(directory)
668
+ }
669
+ params.push(limit)
670
+ if (offset) params.push(offset)
671
+
672
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
673
+ const offsetClause = offset ? "OFFSET ?" : ""
674
+ debug.time("query:recent:index:exec")
675
+ const rows = index.query<Row, (string | number)[]>(`
676
+ SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
677
+ CAST(time_created AS INTEGER) AS time_created, text
678
+ FROM document_index
679
+ ${where}
680
+ ORDER BY CAST(time_created AS INTEGER) DESC
681
+ LIMIT ? ${offsetClause}
682
+ `).all(...params as any[])
683
+ debug.timeEnd("query:recent:index:exec")
684
+ return rows
685
+ } catch (err) {
686
+ debug.log("recent:index:fallback", err instanceof Error ? err.message : String(err))
687
+ return
688
+ }
689
+ }
690
+
625
691
  function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
626
692
  const offsetClause = offset ? "OFFSET ?" : ""
627
693
  const conditions: string[] = [
@@ -664,7 +730,7 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
664
730
  return rows
665
731
  }
666
732
 
667
- function ensureSearchIndex(source: Database, sourcePath: string) {
733
+ function ensureSearchIndex(source: Database, sourcePath: string, options?: { rebuild?: boolean; useStale?: boolean }) {
668
734
  const indexPath = searchIndexPath(sourcePath)
669
735
  if (!_indexDb || _indexDbPath !== indexPath) {
670
736
  _indexDb?.close()
@@ -679,12 +745,35 @@ function ensureSearchIndex(source: Database, sourcePath: string) {
679
745
  const currentPath = getMeta(_indexDb, "source_path")
680
746
  const currentIndexVersion = getMeta(_indexDb, "index_version")
681
747
  if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
748
+ if (options?.rebuild === false) {
749
+ if (options?.useStale) return _indexDb
750
+ return
751
+ }
682
752
  rebuildSearchIndex(source, _indexDb, sourcePath, state)
683
753
  }
684
754
  return _indexDb
685
755
  }
686
756
 
687
- const SEARCH_INDEX_VERSION = "4"
757
+ function scheduleBackgroundIndexRebuild(dbPath: string) {
758
+ if (backgroundIndexRebuilds.has(dbPath)) return
759
+ backgroundIndexRebuilds.add(dbPath)
760
+ debug.log("fts:rebuild:background-scheduled", { dbPath })
761
+ const timer = setTimeout(() => {
762
+ debug.time("fts:rebuild:background")
763
+ try {
764
+ const db = getDb(dbPath)
765
+ ensureSearchIndex(db, dbPath)
766
+ } catch (err) {
767
+ debug.log("fts:rebuild:background:error", err instanceof Error ? err.message : String(err))
768
+ } finally {
769
+ backgroundIndexRebuilds.delete(dbPath)
770
+ debug.timeEnd("fts:rebuild:background")
771
+ }
772
+ }, 250)
773
+ ;(timer as { unref?: () => void }).unref?.()
774
+ }
775
+
776
+ const SEARCH_INDEX_VERSION = "6"
688
777
 
689
778
  function searchIndexPath(sourcePath: string) {
690
779
  const parsed = path.parse(sourcePath)
@@ -710,6 +799,24 @@ function migrateSearchIndex(db: Database) {
710
799
  text,
711
800
  tokenize='unicode61'
712
801
  );
802
+ CREATE TABLE IF NOT EXISTS document_index(
803
+ id TEXT PRIMARY KEY,
804
+ message_id TEXT NOT NULL,
805
+ session_id TEXT NOT NULL,
806
+ session_title TEXT NOT NULL,
807
+ directory TEXT NOT NULL,
808
+ role TEXT NOT NULL,
809
+ part_type TEXT NOT NULL,
810
+ tool TEXT,
811
+ time_created INTEGER NOT NULL,
812
+ text TEXT NOT NULL
813
+ );
814
+ CREATE INDEX IF NOT EXISTS document_index_recent_idx
815
+ ON document_index(directory, role, time_created DESC);
816
+ CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
817
+ ON document_index(directory, role, part_type, time_created DESC);
818
+ CREATE INDEX IF NOT EXISTS document_index_time_idx
819
+ ON document_index(time_created DESC);
713
820
  `)
714
821
 
715
822
  const columns = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().map((column) => column.name)
@@ -731,6 +838,33 @@ function migrateSearchIndex(db: Database) {
731
838
  );
732
839
  `)
733
840
  }
841
+
842
+ const indexColumns = db.query<{ name: string }, []>("PRAGMA table_info(document_index)").all().map((column) => column.name)
843
+ if (!["part_type", "tool", "time_created", "text"].every((name) => indexColumns.includes(name))) {
844
+ db.exec("DROP TABLE IF EXISTS document_index")
845
+ db.exec(`
846
+ CREATE TABLE document_index(
847
+ id TEXT PRIMARY KEY,
848
+ message_id TEXT NOT NULL,
849
+ session_id TEXT NOT NULL,
850
+ session_title TEXT NOT NULL,
851
+ directory TEXT NOT NULL,
852
+ role TEXT NOT NULL,
853
+ part_type TEXT NOT NULL,
854
+ tool TEXT,
855
+ time_created INTEGER NOT NULL,
856
+ text TEXT NOT NULL
857
+ );
858
+ `)
859
+ }
860
+ db.exec(`
861
+ CREATE INDEX IF NOT EXISTS document_index_recent_idx
862
+ ON document_index(directory, role, time_created DESC);
863
+ CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
864
+ ON document_index(directory, role, part_type, time_created DESC);
865
+ CREATE INDEX IF NOT EXISTS document_index_time_idx
866
+ ON document_index(time_created DESC);
867
+ `)
734
868
  }
735
869
 
736
870
  function sourceState(db: Database, sourcePath: string) {
@@ -765,9 +899,14 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
765
899
  INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
766
900
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
767
901
  `)
902
+ const insertIndex = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
903
+ INSERT INTO document_index(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
904
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
905
+ `)
768
906
  index.exec("BEGIN IMMEDIATE")
769
907
  try {
770
908
  index.exec("DELETE FROM document_fts")
909
+ index.exec("DELETE FROM document_index")
771
910
  for (const row of rows.flatMap(indexSourceRowToRows)) {
772
911
  insert.run(
773
912
  row.id,
@@ -781,6 +920,18 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
781
920
  row.time_created,
782
921
  row.text,
783
922
  )
923
+ insertIndex.run(
924
+ row.id,
925
+ row.message_id,
926
+ row.session_id,
927
+ row.session_title ?? "Untitled session",
928
+ row.directory,
929
+ row.role,
930
+ row.part_type ?? "text",
931
+ row.tool ?? null,
932
+ row.time_created,
933
+ row.text,
934
+ )
784
935
  }
785
936
  setMeta(index, "source_path", sourcePath)
786
937
  setMeta(index, "source_data_version", String(state.dataVersion))
package/telescope.tsx CHANGED
@@ -20,9 +20,10 @@ 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
+ debug.log("component:render:start")
26
27
  type OwnerFilter = "all" | SearchRole
27
28
  const dimensions = useTerminalDimensions()
28
29
  const [query, setQuery] = createSignal("")
@@ -46,11 +47,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
46
47
  const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
47
48
  const [prefetchingPreviewBefore, setPrefetchingPreviewBefore] = createSignal(false)
48
49
  const [prefetchingPreviewAfter, setPrefetchingPreviewAfter] = createSignal(false)
50
+ const [previewWindow, setPreviewWindow] = createSignal({ start: 0, end: 0 })
51
+ const [previewHeightVersion, setPreviewHeightVersion] = createSignal(0)
49
52
  const MIN_SEARCH_BATCH_SIZE = 25
50
53
  const MIN_RECENT_BATCH_SIZE = 15
51
54
  const RESULT_OVERSCAN_MULTIPLIER = 2
52
55
  const RESULT_BATCH_VIEWPORTS = 4
53
- const RESULT_PREFETCH_VIEWPORTS = 10
56
+ const RESULT_PREFETCH_VIEWPORTS = 3
54
57
  const RESULT_CACHE_BEHIND_VIEWPORTS = 6
55
58
  const INITIAL_PREVIEW_BEFORE = 20
56
59
  const INITIAL_PREVIEW_AFTER = 30
@@ -87,12 +90,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
87
90
  const directory = props.api.state.path.directory
88
91
  let advanceSelectionAfterLoad = false
89
92
  let advanceSelectionBeforeLoad = false
93
+ let resultNavigationStarted = false
90
94
  let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
91
95
  let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
92
96
  let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
93
97
  let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
94
98
  let pendingPreviewBefore: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
95
99
  let pendingPreviewAfterVisible = false
100
+ const previewMeasuredHeights = new Map<string, number>()
96
101
  const cancelPreviewPrefetch = () => {
97
102
  if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
98
103
  if (previewAfterTimer) clearTimeout(previewAfterTimer)
@@ -177,6 +182,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
177
182
  resultPreviousTimer = undefined
178
183
  advanceSelectionAfterLoad = false
179
184
  advanceSelectionBeforeLoad = false
185
+ resultNavigationStarted = false
180
186
  setLoadingMore(false)
181
187
  setLoadingPreviousResults(false)
182
188
  setPrefetchingResults(false)
@@ -187,9 +193,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
187
193
  setLoading(true)
188
194
  const limit = recentBatchSize()
189
195
  const timer = setTimeout(() => {
196
+ debug.log("bootstrap:recent:start", { limit, directory: dir, role })
190
197
  debug.time("query:recent")
191
198
  try {
192
199
  const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
200
+ debug.log("bootstrap:recent:done", { rows: batch.length, limit })
193
201
  solidBatch(() => {
194
202
  setResults(batch)
195
203
  setResultBaseOffset(0)
@@ -199,6 +207,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
199
207
  setSelected(0)
200
208
  })
201
209
  } catch (err) {
210
+ debug.log("bootstrap:recent:error", err instanceof Error ? err.message : String(err))
202
211
  solidBatch(() => {
203
212
  setResults([])
204
213
  setResultBaseOffset(0)
@@ -209,6 +218,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
209
218
  }
210
219
  debug.timeEnd("query:recent")
211
220
  setLoading(false)
221
+ debug.log("component:interactive")
212
222
  }, 1)
213
223
  onCleanup(() => clearTimeout(timer))
214
224
  return
@@ -220,7 +230,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
220
230
  const timer = setTimeout(() => {
221
231
  debug.time("query:search")
222
232
  try {
233
+ debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q })
223
234
  const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
235
+ debug.log("bootstrap:search:done", { rows: batch.length, limit })
224
236
  solidBatch(() => {
225
237
  setResults(batch)
226
238
  setResultBaseOffset(0)
@@ -370,6 +382,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
370
382
 
371
383
  const move = (delta: number) => {
372
384
  if (results().length === 0) return
385
+ resultNavigationStarted = true
373
386
  setSelected((index) => {
374
387
  const base = resultBaseOffset()
375
388
  const cachedEnd = base + results().length
@@ -409,6 +422,8 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
409
422
  const state = resultPrefetchState()
410
423
  const blockedBy = !hasMore()
411
424
  ? "no-more"
425
+ : !resultNavigationStarted
426
+ ? "waiting-for-navigation"
412
427
  : loadingMore()
413
428
  ? "loading-more"
414
429
  : loadingPreviousResults()
@@ -431,10 +446,76 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
431
446
  onCleanup(() => clearTimeout(timer))
432
447
  })
433
448
 
434
- const previewContentHeight = () => {
435
- return previewScroll?.scrollHeight ?? 0
449
+ const estimatePreviewPartHeight = (part: ConversationPreviewPart) => {
450
+ if (part.type === "tool") {
451
+ if (!part.target) return 2
452
+ if (part.tool === "write") return 40
453
+ if (part.tool === "edit" || part.tool === "apply_patch") return 35
454
+ return 2
455
+ }
456
+ if (part.type === "reasoning") return Math.min(24, Math.max(2, Math.ceil(part.text.length / 120)))
457
+ return Math.min(90, Math.max(3, Math.ceil(part.text.length / 90)))
436
458
  }
437
459
 
460
+ const previewPartHeight = (part: ConversationPreviewPart) => previewMeasuredHeights.get(part.id) ?? estimatePreviewPartHeight(part)
461
+
462
+ const previewVirtualLayout = createMemo(() => {
463
+ previewHeightVersion()
464
+ let offset = 0
465
+ return previewParts().map((part) => {
466
+ const height = previewPartHeight(part)
467
+ const row = { part, top: offset, bottom: offset + height, height }
468
+ offset += height
469
+ return row
470
+ })
471
+ })
472
+
473
+ const previewContentHeight = () => previewVirtualLayout().at(-1)?.bottom ?? 0
474
+
475
+ const updatePreviewWindow = () => {
476
+ const scroll = previewScroll
477
+ const layout = previewVirtualLayout()
478
+ if (layout.length === 0) {
479
+ setPreviewWindow({ start: 0, end: 0 })
480
+ return
481
+ }
482
+ if (!scroll) {
483
+ const next = { start: 0, end: Math.min(layout.length, 20) }
484
+ const current = previewWindow()
485
+ if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
486
+ return
487
+ }
488
+
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)) }
497
+ const current = previewWindow()
498
+ if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
499
+ }
500
+
501
+ const previewWindowParts = createMemo(() => {
502
+ const { start, end } = previewWindow()
503
+ return previewParts().slice(start, end)
504
+ })
505
+
506
+ const previewTopSpacerHeight = createMemo(() => {
507
+ const { start } = previewWindow()
508
+ return previewVirtualLayout()[start]?.top ?? 0
509
+ })
510
+
511
+ const previewBottomSpacerHeight = createMemo(() => {
512
+ const { end } = previewWindow()
513
+ const layout = previewVirtualLayout()
514
+ const total = layout.at(-1)?.bottom ?? 0
515
+ const renderedBottom = end > 0 ? layout[end - 1]?.bottom ?? 0 : 0
516
+ return Math.max(0, total - renderedBottom)
517
+ })
518
+
438
519
  const previewScrollState = () => {
439
520
  const scroll = previewScroll
440
521
  const children = scroll?.getChildren()
@@ -456,6 +537,45 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
456
537
  }
457
538
  }
458
539
 
540
+ const measurePreviewWindow = () => {
541
+ const scroll = previewScroll
542
+ if (!scroll) return
543
+
544
+ let changed = false
545
+ for (const part of previewWindowParts()) {
546
+ const node = findRenderableByID(scroll, `preview-part-${part.id}`)
547
+ const height = node?.height
548
+ if (!height || height <= 0) continue
549
+ if (previewMeasuredHeights.get(part.id) !== height) {
550
+ previewMeasuredHeights.set(part.id, height)
551
+ changed = true
552
+ }
553
+ }
554
+
555
+ if (changed) {
556
+ debug.log("preview:measure", {
557
+ measured: previewWindowParts().length,
558
+ totalMeasured: previewMeasuredHeights.size,
559
+ window: previewWindow(),
560
+ })
561
+ setPreviewHeightVersion((value) => value + 1)
562
+ setTimeout(updatePreviewWindow, 1)
563
+ }
564
+ }
565
+
566
+ createEffect(() => {
567
+ previewParts()
568
+ previewHeightVersion()
569
+ const timer = setTimeout(updatePreviewWindow, 1)
570
+ onCleanup(() => clearTimeout(timer))
571
+ })
572
+
573
+ createEffect(() => {
574
+ previewWindowParts()
575
+ const timer = setTimeout(measurePreviewWindow, 1)
576
+ onCleanup(() => clearTimeout(timer))
577
+ })
578
+
459
579
  const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
460
580
  const item = selectedResult()
461
581
  const first = previewParts()[0]
@@ -498,6 +618,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
498
618
  const beforeAdjust = previewScrollState()
499
619
  const delta = previewContentHeight() - previousContentHeight
500
620
  if (delta > 0) previewScroll?.scrollBy(delta)
621
+ updatePreviewWindow()
501
622
  debug.log("preview:load-before:adjust", {
502
623
  delta,
503
624
  previousContentHeight,
@@ -509,6 +630,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
509
630
  }, 1)
510
631
  } else {
511
632
  setTimeout(() => {
633
+ updatePreviewWindow()
512
634
  debug.log("preview:load-before:no-adjust", {
513
635
  previousContentHeight,
514
636
  newContentHeight: previewContentHeight(),
@@ -555,7 +677,10 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
555
677
  first: page.parts[0]?.id,
556
678
  last: page.parts.at(-1)?.id,
557
679
  })
558
- if (page.parts.length > 0) setPreviewParts((prev) => [...prev, ...page.parts])
680
+ if (page.parts.length > 0) {
681
+ setPreviewParts((prev) => [...prev, ...page.parts])
682
+ setTimeout(updatePreviewWindow, 1)
683
+ }
559
684
  setHasMorePreviewAfter(page.hasMoreAfter)
560
685
  } catch (err) {
561
686
  debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
@@ -631,12 +756,32 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
631
756
  }, 1)
632
757
  }
633
758
 
759
+ const scrollPreviewToEstimatedTarget = (item: SearchResult) => {
760
+ const scroll = previewScroll
761
+ if (!scroll) return
762
+ const targetID = messageTargetID(item)
763
+ const row = previewVirtualLayout().find((entry) => entry.part.id === item.id)
764
+ if (!row) {
765
+ scrollPreviewToTarget(scroll, targetID)
766
+ return
767
+ }
768
+
769
+ const top = Math.max(0, row.top - Math.max(1, Math.floor(scroll.height / 3)))
770
+ debug.log("preview:target-scroll:estimated", { targetID, targetTop: row.top, scrollTop: scroll.scrollTop, nextScrollTop: top, window: previewWindow() })
771
+ scroll.scrollTo(top)
772
+ updatePreviewWindow()
773
+ setTimeout(() => scrollPreviewToTarget(scroll, targetID), 1)
774
+ }
775
+
634
776
  let lastPreviewItemId = ""
635
777
  createEffect(() => {
636
778
  const item = selectedResult()
637
779
  if (!item) {
638
780
  cancelPreviewPrefetch()
639
781
  setPreviewParts([])
782
+ previewMeasuredHeights.clear()
783
+ setPreviewHeightVersion((value) => value + 1)
784
+ setPreviewWindow({ start: 0, end: 0 })
640
785
  setHasMorePreviewBefore(false)
641
786
  setHasMorePreviewAfter(false)
642
787
  return
@@ -644,6 +789,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
644
789
  if (item.id === lastPreviewItemId) return
645
790
  lastPreviewItemId = item.id
646
791
  cancelPreviewPrefetch()
792
+ previewMeasuredHeights.clear()
793
+ setPreviewHeightVersion((value) => value + 1)
794
+ setPreviewWindow({ start: 0, end: 0 })
647
795
  debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
648
796
  const db = dbPath()
649
797
  debug.time("preview:load")
@@ -661,6 +809,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
661
809
  setPreviewParts(page.parts)
662
810
  setHasMorePreviewBefore(page.hasMoreBefore)
663
811
  setHasMorePreviewAfter(page.hasMoreAfter)
812
+ setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
664
813
  } catch {}
665
814
  debug.timeEnd("nav:total")
666
815
  debug.timeEnd("preview:load")
@@ -674,7 +823,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
674
823
  const scroll = previewScroll
675
824
  const children = scroll?.getChildren()
676
825
  if (!scroll || !children || children.length === 0) return
677
- const totalContentHeight = scroll.scrollHeight
826
+ const totalContentHeight = previewContentHeight()
678
827
  const atTop = scroll.scrollTop <= 0
679
828
  const prefetchDistance = Math.max(2, Math.floor(scroll.height * PREVIEW_PREFETCH_VIEWPORTS))
680
829
  const nearTop = scroll.scrollTop <= prefetchDistance
@@ -708,11 +857,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
708
857
  let scrolledItem = ""
709
858
  createEffect(() => {
710
859
  const item = selectedResult()
711
- previewParts()
860
+ previewVirtualLayout()
712
861
  if (!item) return
713
862
  if (item.id === scrolledItem) return
714
863
  scrolledItem = item.id
715
- const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
864
+ const timer = setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
716
865
  onCleanup(() => clearTimeout(timer))
717
866
  })
718
867
 
@@ -742,7 +891,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
742
891
  return
743
892
  }
744
893
 
745
- const totalContentHeight = scroll.scrollHeight
894
+ const totalContentHeight = previewContentHeight()
746
895
  if (direction > 0 && scroll.scrollTop + scroll.height >= totalContentHeight - 1 && hasMorePreviewAfter()) {
747
896
  debug.log("preview:scroll-key:load-after", { direction, totalContentHeight, state: beforeState })
748
897
  schedulePreviewAfter(true)
@@ -751,6 +900,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
751
900
 
752
901
  const amount = direction * previewScrollAmount(scroll)
753
902
  scroll.scrollBy(amount)
903
+ updatePreviewWindow()
754
904
  debug.log("preview:scroll-key:scroll", { direction, amount, before: beforeState, after: previewScrollState() })
755
905
  }
756
906
 
@@ -919,7 +1069,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
919
1069
  <PreviewHeader item={selectedResult()} query={query()} theme={theme()} />
920
1070
  <scrollbox ref={(element: ScrollBoxRenderable) => (previewScroll = element)} flexGrow={1} minHeight={0} paddingLeft={1} paddingRight={1} verticalScrollbarOptions={{ visible: true }}>
921
1071
  <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()} />}
1072
+ {(item) => (
1073
+ <box flexDirection="column" flexShrink={0}>
1074
+ <box height={previewTopSpacerHeight()} flexShrink={0} />
1075
+ <ConversationPreview item={item()} parts={previewWindowParts()} syntax={syntax()} theme={theme()} />
1076
+ <box height={previewBottomSpacerHeight()} flexShrink={0} />
1077
+ </box>
1078
+ )}
923
1079
  </Show>
924
1080
  </scrollbox>
925
1081
  </box>
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 } = {
@@ -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()) {