@bojackduy/opencode-telescope 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-telescope",
4
- "version": "0.1.12",
4
+ "version": "0.1.13",
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
@@ -40,6 +40,17 @@ export type ConversationPreviewPart = {
40
40
  target: boolean
41
41
  }
42
42
 
43
+ export type ConversationPreviewPage = {
44
+ parts: ConversationPreviewPart[]
45
+ hasMoreBefore: boolean
46
+ hasMoreAfter: boolean
47
+ }
48
+
49
+ export type ConversationPreviewCursor = {
50
+ id: string
51
+ timeCreated: number
52
+ }
53
+
43
54
  export type ToolState = {
44
55
  status: "pending" | "running" | "completed" | "error"
45
56
  input?: unknown
@@ -132,7 +143,7 @@ export function recentSessionMessages(options?: { limit?: number; offset?: numbe
132
143
  )
133
144
  }
134
145
 
135
- export function loadConversationWindow(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }) {
146
+ export function loadConversationAround(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }): ConversationPreviewPage {
136
147
  debug.time("preview:query:total")
137
148
  const db = getDb(options?.dbPath)
138
149
  const before = options?.before ?? 3
@@ -144,12 +155,19 @@ export function loadConversationWindow(result: SearchResult, options?: { before?
144
155
  ).get(result.id)
145
156
  debug.timeEnd("preview:query:hit")
146
157
  if (!hit) {
158
+ debug.log("preview:window", {
159
+ item: result.id,
160
+ session: result.sessionID,
161
+ before,
162
+ after,
163
+ hit: false,
164
+ })
147
165
  debug.timeEnd("preview:query:total")
148
- return []
166
+ return { parts: [], hasMoreBefore: false, hasMoreAfter: false }
149
167
  }
150
168
 
151
- const fetchBefore = Math.max(before * 4, 30)
152
- const fetchAfter = Math.max(after * 4, 50)
169
+ const fetchBefore = Math.max(before * 4, before + 1, 30)
170
+ const fetchAfter = Math.max((after + 1) * 4, after + 2, 50)
153
171
 
154
172
  debug.time("preview:query:before")
155
173
  const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
@@ -182,17 +200,108 @@ export function loadConversationWindow(result: SearchResult, options?: { before?
182
200
  debug.timeEnd("preview:query:after")
183
201
 
184
202
  debug.time("preview:query:parse")
185
- const isValid = (row: ConversationRow) =>
186
- (row.role === "user" || row.role === "assistant") &&
187
- (row.type === "text" || row.type === "reasoning" || row.type === "tool")
188
-
203
+ const validBefore = beforeRows.filter(isPreviewRow)
204
+ const validAfter = afterRows.filter(isPreviewRow)
189
205
  const parts = [
190
- ...beforeRows.filter(isValid).slice(0, before).reverse(),
191
- ...afterRows.filter(isValid).slice(0, after + 1),
206
+ ...validBefore.slice(0, before).reverse(),
207
+ ...validAfter.slice(0, after + 1),
192
208
  ].flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
209
+ const page = {
210
+ parts,
211
+ hasMoreBefore: validBefore.length > before,
212
+ hasMoreAfter: validAfter.length > after + 1,
213
+ }
214
+ debug.log("preview:window", {
215
+ item: result.id,
216
+ session: result.sessionID,
217
+ mode: "around",
218
+ before,
219
+ after,
220
+ fetchBefore,
221
+ fetchAfter,
222
+ beforeRows: beforeRows.length,
223
+ afterRows: afterRows.length,
224
+ parts: parts.length,
225
+ hasMoreBefore: page.hasMoreBefore,
226
+ hasMoreAfter: page.hasMoreAfter,
227
+ first: parts[0]?.id,
228
+ last: parts.at(-1)?.id,
229
+ })
193
230
  debug.timeEnd("preview:query:parse")
194
231
  debug.timeEnd("preview:query:total")
195
- return parts
232
+ return page
233
+ }
234
+
235
+ export function loadConversationBefore(result: SearchResult, cursor: ConversationPreviewCursor, options?: { limit?: number; dbPath?: string }) {
236
+ debug.time("preview:query:before-page")
237
+ const db = getDb(options?.dbPath)
238
+ const limit = options?.limit ?? 20
239
+ const fetchLimit = Math.max(limit * 4, limit + 1, 30)
240
+ const rows = db.query<ConversationRow, [string, number, number, string, number]>(`
241
+ SELECT p.id, p.message_id, p.session_id,
242
+ json_extract(m.data, '$.role') AS role,
243
+ json_extract(p.data, '$.type') AS type,
244
+ p.time_created, p.data
245
+ FROM part p
246
+ JOIN message m ON m.id = p.message_id
247
+ WHERE p.session_id = ?
248
+ AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
249
+ ORDER BY p.time_created DESC, p.id DESC
250
+ LIMIT ?
251
+ `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
252
+ const valid = rows.filter(isPreviewRow)
253
+ const parts = valid.slice(0, limit).reverse().flatMap((row) => parseConversationPart(row, false) ?? [])
254
+ const page = { parts, hasMoreBefore: valid.length > limit }
255
+ debug.log("preview:window", {
256
+ item: result.id,
257
+ session: result.sessionID,
258
+ mode: "before",
259
+ cursor,
260
+ limit,
261
+ rows: rows.length,
262
+ parts: parts.length,
263
+ hasMoreBefore: page.hasMoreBefore,
264
+ first: parts[0]?.id,
265
+ last: parts.at(-1)?.id,
266
+ })
267
+ debug.timeEnd("preview:query:before-page")
268
+ return page
269
+ }
270
+
271
+ export function loadConversationAfter(result: SearchResult, cursor: ConversationPreviewCursor, options?: { limit?: number; dbPath?: string }) {
272
+ debug.time("preview:query:after-page")
273
+ const db = getDb(options?.dbPath)
274
+ const limit = options?.limit ?? 20
275
+ const fetchLimit = Math.max(limit * 4, limit + 1, 30)
276
+ const rows = db.query<ConversationRow, [string, number, number, string, number]>(`
277
+ SELECT p.id, p.message_id, p.session_id,
278
+ json_extract(m.data, '$.role') AS role,
279
+ json_extract(p.data, '$.type') AS type,
280
+ p.time_created, p.data
281
+ FROM part p
282
+ JOIN message m ON m.id = p.message_id
283
+ WHERE p.session_id = ?
284
+ AND (p.time_created > ? OR (p.time_created = ? AND p.id > ?))
285
+ ORDER BY p.time_created ASC, p.id ASC
286
+ LIMIT ?
287
+ `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
288
+ const valid = rows.filter(isPreviewRow)
289
+ const parts = valid.slice(0, limit).flatMap((row) => parseConversationPart(row, false) ?? [])
290
+ const page = { parts, hasMoreAfter: valid.length > limit }
291
+ debug.log("preview:window", {
292
+ item: result.id,
293
+ session: result.sessionID,
294
+ mode: "after",
295
+ cursor,
296
+ limit,
297
+ rows: rows.length,
298
+ parts: parts.length,
299
+ hasMoreAfter: page.hasMoreAfter,
300
+ first: parts[0]?.id,
301
+ last: parts.at(-1)?.id,
302
+ })
303
+ debug.timeEnd("preview:query:after-page")
304
+ return page
196
305
  }
197
306
 
198
307
  export function rowToSearchResult(row: Row, query: string): SearchResult | undefined {
@@ -399,6 +508,11 @@ function parseConversationPart(row: ConversationRow, target: boolean): Conversat
399
508
  }
400
509
  }
401
510
 
511
+ function isPreviewRow(row: ConversationRow) {
512
+ return (row.role === "user" || row.role === "assistant") &&
513
+ (row.type === "text" || row.type === "reasoning" || row.type === "tool")
514
+ }
515
+
402
516
  function parsePartData(data: string) {
403
517
  try {
404
518
  const value = JSON.parse(data) as unknown
package/telescope.tsx CHANGED
@@ -6,7 +6,9 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "so
6
6
  import { ConversationPreview, PreviewHeader } from "./components/preview.tsx"
7
7
  import { EmptyState, ResultRow, SkeletonRow } from "./components/result-list.tsx"
8
8
  import {
9
- loadConversationWindow,
9
+ loadConversationAfter,
10
+ loadConversationAround,
11
+ loadConversationBefore,
10
12
  recentSessionMessages,
11
13
  resolveDatabasePath,
12
14
  searchSessionMessages,
@@ -30,12 +32,18 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
30
32
  const [loading, setLoading] = createSignal(true)
31
33
  const [hasMore, setHasMore] = createSignal(true)
32
34
  const [loadingMore, setLoadingMore] = createSignal(false)
33
- const BATCH_SIZE = 25
34
- const RECENT_BATCH_SIZE = 15
35
- const INITIAL_PREVIEW_BEFORE = 3
36
- const INITIAL_PREVIEW_AFTER = 6
37
- const PREVIEW_EXPAND_STEP = 5
38
- const [previewRange, setPreviewRange] = createSignal({ before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
35
+ const [prefetchingResults, setPrefetchingResults] = createSignal(false)
36
+ const [resultPageInfo, setResultPageInfo] = createSignal({ loadedUntil: 0, hasMore: true, pageSize: 0, lastOffset: 0, lastAdded: 0 })
37
+ const [hasMorePreviewBefore, setHasMorePreviewBefore] = createSignal(false)
38
+ const [hasMorePreviewAfter, setHasMorePreviewAfter] = createSignal(false)
39
+ const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
40
+ const MIN_SEARCH_BATCH_SIZE = 25
41
+ const MIN_RECENT_BATCH_SIZE = 15
42
+ const RESULT_OVERSCAN_MULTIPLIER = 2
43
+ const RESULT_PREFETCH_VIEWPORTS = 3
44
+ const INITIAL_PREVIEW_BEFORE = 20
45
+ const INITIAL_PREVIEW_AFTER = 30
46
+ const PREVIEW_PAGE_SIZE = 20
39
47
  let input: InputRenderable | undefined
40
48
  let resultScroll: ScrollBoxRenderable | undefined
41
49
  let previewScroll: ScrollBoxRenderable | undefined
@@ -49,6 +57,47 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
49
57
  const verticalOffset = createMemo(() => Math.floor(dimensions().height / 4 - height() / 2) - 2)
50
58
  const dbPath = createMemo(() => resolveDatabasePath())
51
59
  const directory = props.api.state.path.directory
60
+ let advanceSelectionAfterLoad = false
61
+ let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
62
+ onCleanup(() => {
63
+ if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
64
+ })
65
+
66
+ const resultRowHeight = createMemo(() => leftWidth() >= 48 ? 3 : 4)
67
+ const visibleResultRows = () => {
68
+ const viewportHeight = resultScroll?.height || Math.max(8, height() - 7)
69
+ return Math.max(5, Math.floor(viewportHeight / resultRowHeight()))
70
+ }
71
+ const searchBatchSize = () => Math.max(MIN_SEARCH_BATCH_SIZE, visibleResultRows() * 2)
72
+ const recentBatchSize = () => Math.max(MIN_RECENT_BATCH_SIZE, visibleResultRows() * 2)
73
+ const resultPrefetchThreshold = () => Math.max(visibleResultRows() * RESULT_PREFETCH_VIEWPORTS, searchBatchSize())
74
+ const resultRenderWindow = createMemo(() => {
75
+ const total = results().length
76
+ const visible = visibleResultRows()
77
+ const overscan = visible * RESULT_OVERSCAN_MULTIPLIER
78
+ const index = selected()
79
+ const start = Math.max(0, index - overscan)
80
+ const end = Math.min(total, index + visible + overscan)
81
+ return { start, end, items: results().slice(start, end) }
82
+ })
83
+
84
+ let lastResultRenderWindow = ""
85
+ createEffect(() => {
86
+ const window = resultRenderWindow()
87
+ const key = `${window.start}:${window.end}:${results().length}`
88
+ if (key === lastResultRenderWindow) return
89
+ lastResultRenderWindow = key
90
+ debug.log("results:render-window", {
91
+ selected: selected(),
92
+ totalLoaded: results().length,
93
+ start: window.start,
94
+ end: window.end,
95
+ rendered: window.items.length,
96
+ visibleRows: visibleResultRows(),
97
+ overscanRows: visibleResultRows() * RESULT_OVERSCAN_MULTIPLIER,
98
+ pageInfo: resultPageInfo(),
99
+ })
100
+ })
52
101
 
53
102
  createEffect(() => {
54
103
  const q = query().trim()
@@ -59,15 +108,18 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
59
108
 
60
109
  if (!q) {
61
110
  setLoading(true)
111
+ const limit = recentBatchSize()
62
112
  const timer = setTimeout(() => {
63
113
  debug.time("query:recent")
64
114
  try {
65
- const batch = recentSessionMessages({ limit: RECENT_BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
115
+ const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir })
66
116
  setResults(batch)
67
- setHasMore(batch.length >= RECENT_BATCH_SIZE)
117
+ setHasMore(batch.length >= limit)
118
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
68
119
  setSelected(0)
69
120
  } catch (err) {
70
121
  setResults([])
122
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
71
123
  setError(err instanceof Error ? err.message : String(err))
72
124
  }
73
125
  debug.timeEnd("query:recent")
@@ -79,15 +131,18 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
79
131
 
80
132
  setLoading(true)
81
133
  setBusy(true)
134
+ const limit = searchBatchSize()
82
135
  const timer = setTimeout(() => {
83
136
  debug.time("query:search")
84
137
  try {
85
- const batch = searchSessionMessages(q, { limit: BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
138
+ const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir })
86
139
  setResults(batch)
87
- setHasMore(batch.length >= BATCH_SIZE)
140
+ setHasMore(batch.length >= limit)
141
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
88
142
  setSelected(0)
89
143
  } catch (err) {
90
144
  setResults([])
145
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
91
146
  setError(err instanceof Error ? err.message : String(err))
92
147
  } finally {
93
148
  debug.timeEnd("query:search")
@@ -98,14 +153,67 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
98
153
  onCleanup(() => clearTimeout(timer))
99
154
  })
100
155
 
156
+ const loadMoreResults = (advance = false) => {
157
+ if (advance) advanceSelectionAfterLoad = true
158
+ if (!hasMore()) {
159
+ advanceSelectionAfterLoad = false
160
+ return
161
+ }
162
+ if (loadingMore() || prefetchingResults() || busy() || loading()) return
163
+
164
+ const total = results().length
165
+ const q = query().trim()
166
+ const db = dbPath()
167
+ const dir = directory
168
+ const limit = q ? searchBatchSize() : recentBatchSize()
169
+
170
+ advance ? setLoadingMore(true) : setPrefetchingResults(true)
171
+ debug.time("query:load-more")
172
+ try {
173
+ const batch = q
174
+ ? searchSessionMessages(q, { limit, offset: total, dbPath: db, directory: dir })
175
+ : recentSessionMessages({ limit, offset: total, dbPath: db, directory: dir })
176
+ const nextHasMore = batch.length >= limit
177
+ const nextLoadedUntil = total + batch.length
178
+ debug.log("results:prefetch", { offset: total, limit, added: batch.length, totalLoaded: nextLoadedUntil, hasMore: nextHasMore, advance })
179
+ setResults((prev) => [...prev, ...batch])
180
+ setResultPageInfo({ loadedUntil: nextLoadedUntil, hasMore: nextHasMore, pageSize: limit, lastOffset: total, lastAdded: batch.length })
181
+ if (!nextHasMore) setHasMore(false)
182
+ if (advanceSelectionAfterLoad) {
183
+ debug.log("results:advance-after-load", { from: selected(), total, added: batch.length })
184
+ advanceSelectionAfterLoad = false
185
+ if (batch.length > 0) setSelected(total)
186
+ }
187
+ } catch (err) {
188
+ debug.log("results:load-more:error", err instanceof Error ? err.message : String(err))
189
+ advanceSelectionAfterLoad = false
190
+ } finally {
191
+ debug.timeEnd("query:load-more")
192
+ advance ? setLoadingMore(false) : setPrefetchingResults(false)
193
+ }
194
+ }
195
+
196
+ const scheduleResultPrefetch = (advance = false) => {
197
+ if (advance) advanceSelectionAfterLoad = true
198
+ if (resultPrefetchTimer || loadingMore() || prefetchingResults()) return
199
+ debug.log("results:prefetch-scheduled", { advance, pendingAdvance: advanceSelectionAfterLoad, pageInfo: resultPageInfo() })
200
+ resultPrefetchTimer = setTimeout(() => {
201
+ resultPrefetchTimer = undefined
202
+ loadMoreResults(advanceSelectionAfterLoad)
203
+ }, 1)
204
+ }
205
+
101
206
  const move = (delta: number) => {
102
207
  if (results().length === 0) return
103
208
  setSelected((index) => {
104
209
  const next = index + delta
105
210
  let finalIndex = next
106
211
  if (next < 0) finalIndex = results().length - 1
107
- else if (next >= results().length) finalIndex = results().length - 1
108
-
212
+ else if (next >= results().length) {
213
+ if (hasMore()) scheduleResultPrefetch(true)
214
+ finalIndex = results().length - 1
215
+ }
216
+
109
217
  if (finalIndex !== index) debug.time("nav:total")
110
218
  return finalIndex
111
219
  })
@@ -113,7 +221,8 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
113
221
 
114
222
  createEffect(() => {
115
223
  const index = selected()
116
- const row = resultScroll?.getChildren()[index]
224
+ const window = resultRenderWindow()
225
+ const row = resultScroll?.getChildren()[index - window.start]
117
226
  if (!resultScroll || !row) return
118
227
  const y = row.y - resultScroll.y
119
228
  if (y < 0) {
@@ -128,50 +237,107 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
128
237
  createEffect(() => {
129
238
  const index = selected()
130
239
  const total = results().length
131
- if (index < total - 3) return
132
- if (!hasMore() || loadingMore() || busy() || loading()) return
240
+ const threshold = resultPrefetchThreshold()
241
+ if (index < total - threshold) return
242
+ if (!hasMore() || loadingMore() || prefetchingResults() || busy() || loading()) return
133
243
 
134
- const q = query().trim()
135
- const db = dbPath()
136
- const dir = directory
244
+ const timer = setTimeout(() => scheduleResultPrefetch(false), 100)
245
+ onCleanup(() => clearTimeout(timer))
246
+ })
137
247
 
138
- setLoadingMore(true)
139
- const timer = setTimeout(() => {
140
- debug.time("query:load-more")
141
- try {
142
- const batch = q
143
- ? searchSessionMessages(q, { limit: BATCH_SIZE, offset: total, dbPath: db, directory: dir })
144
- : recentSessionMessages({ limit: RECENT_BATCH_SIZE, offset: total, dbPath: db, directory: dir })
145
- setResults((prev) => [...prev, ...batch])
146
- if (batch.length < (q ? BATCH_SIZE : RECENT_BATCH_SIZE)) setHasMore(false)
147
- } catch {
148
- // keep existing results on error
149
- } finally {
150
- debug.timeEnd("query:load-more")
151
- setLoadingMore(false)
248
+ const previewContentHeight = () => {
249
+ const children = previewScroll?.getChildren()
250
+ const lastChild = children?.[children.length - 1] as { y: number; height: number } | undefined
251
+ return lastChild ? lastChild.y + lastChild.height : 0
252
+ }
253
+
254
+ const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true) => {
255
+ const item = selectedResult()
256
+ const first = previewParts()[0]
257
+ if (!item || !first || loadingPreviewMore()) return
258
+ setLoadingPreviewMore(true)
259
+ debug.time("preview:load-before")
260
+ try {
261
+ const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
262
+ debug.log("preview:load-before", {
263
+ item: item.id,
264
+ added: page.parts.length,
265
+ hasMoreBefore: page.hasMoreBefore,
266
+ preserveScroll,
267
+ first: page.parts[0]?.id,
268
+ last: page.parts.at(-1)?.id,
269
+ })
270
+ if (page.parts.length > 0) {
271
+ setPreviewParts((prev) => [...page.parts, ...prev])
272
+ if (preserveScroll) {
273
+ setTimeout(() => {
274
+ const delta = previewContentHeight() - previousContentHeight
275
+ if (delta > 0) previewScroll?.scrollBy(delta)
276
+ }, 1)
277
+ }
152
278
  }
153
- }, 100)
154
- onCleanup(() => { clearTimeout(timer); setLoadingMore(false) })
155
- })
279
+ setHasMorePreviewBefore(page.hasMoreBefore)
280
+ } catch (err) {
281
+ debug.log("preview:load-before:error", err instanceof Error ? err.message : String(err))
282
+ } finally {
283
+ debug.timeEnd("preview:load-before")
284
+ setLoadingPreviewMore(false)
285
+ }
286
+ }
287
+
288
+ const loadPreviewAfter = () => {
289
+ const item = selectedResult()
290
+ const last = previewParts().at(-1)
291
+ if (!item || !last || loadingPreviewMore()) return
292
+ setLoadingPreviewMore(true)
293
+ debug.time("preview:load-after")
294
+ try {
295
+ const page = loadConversationAfter(item, { id: last.id, timeCreated: last.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
296
+ debug.log("preview:load-after", {
297
+ item: item.id,
298
+ added: page.parts.length,
299
+ hasMoreAfter: page.hasMoreAfter,
300
+ first: page.parts[0]?.id,
301
+ last: page.parts.at(-1)?.id,
302
+ })
303
+ if (page.parts.length > 0) setPreviewParts((prev) => [...prev, ...page.parts])
304
+ setHasMorePreviewAfter(page.hasMoreAfter)
305
+ } catch (err) {
306
+ debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
307
+ } finally {
308
+ debug.timeEnd("preview:load-after")
309
+ setLoadingPreviewMore(false)
310
+ }
311
+ }
156
312
 
157
313
  let lastPreviewItemId = ""
158
314
  createEffect(() => {
159
315
  const item = selectedResult()
160
- const range = previewRange()
161
316
  if (!item) {
162
317
  setPreviewParts([])
318
+ setHasMorePreviewBefore(false)
319
+ setHasMorePreviewAfter(false)
163
320
  return
164
321
  }
165
- if (item.id !== lastPreviewItemId) {
166
- lastPreviewItemId = item.id
167
- debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
168
- setPreviewRange({ before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
169
- return
170
- }
322
+ if (item.id === lastPreviewItemId) return
323
+ lastPreviewItemId = item.id
324
+ debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
171
325
  const db = dbPath()
172
326
  debug.time("preview:load")
173
327
  try {
174
- setPreviewParts(loadConversationWindow(item, { before: range.before, after: range.after, dbPath: db }))
328
+ const page = loadConversationAround(item, { before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER, dbPath: db })
329
+ debug.log("preview:init", {
330
+ item: item.id,
331
+ session: item.sessionID,
332
+ parts: page.parts.length,
333
+ hasMoreBefore: page.hasMoreBefore,
334
+ hasMoreAfter: page.hasMoreAfter,
335
+ first: page.parts[0]?.id,
336
+ last: page.parts.at(-1)?.id,
337
+ })
338
+ setPreviewParts(page.parts)
339
+ setHasMorePreviewBefore(page.hasMoreBefore)
340
+ setHasMorePreviewAfter(page.hasMoreAfter)
175
341
  } catch {}
176
342
  debug.timeEnd("nav:total")
177
343
  debug.timeEnd("preview:load")
@@ -181,16 +347,30 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
181
347
  const item = selectedResult()
182
348
  if (!item) return
183
349
  const interval = setInterval(() => {
350
+ if (loadingPreviewMore()) return
184
351
  const scroll = previewScroll
185
352
  const children = scroll?.getChildren()
186
353
  if (!scroll || !children || children.length === 0) return
187
- const range = previewRange()
188
354
  const lastChild = children[children.length - 1] as { y: number; height: number }
189
355
  const totalContentHeight = lastChild.y + lastChild.height
190
- const atTop = scroll.y <= 0 && range.before > 0
356
+ const atTop = scroll.y <= 0
357
+ const nearTop = scroll.y <= 2
191
358
  const atBottom = scroll.y + scroll.height >= totalContentHeight - 1
192
- if (atTop) setPreviewRange((prev) => ({ ...prev, before: prev.before + PREVIEW_EXPAND_STEP }))
193
- if (atBottom) setPreviewRange((prev) => ({ ...prev, after: prev.after + PREVIEW_EXPAND_STEP }))
359
+ if (nearTop || atBottom) {
360
+ debug.log("preview:scroll-edge", {
361
+ y: scroll.y,
362
+ height: scroll.height,
363
+ contentHeight: totalContentHeight,
364
+ atTop,
365
+ nearTop,
366
+ atBottom,
367
+ hasMoreBefore: hasMorePreviewBefore(),
368
+ hasMoreAfter: hasMorePreviewAfter(),
369
+ children: children.length,
370
+ })
371
+ }
372
+ if (nearTop && hasMorePreviewBefore()) loadPreviewBefore(totalContentHeight, !atTop)
373
+ if (atBottom && hasMorePreviewAfter()) loadPreviewAfter()
194
374
  }, 400)
195
375
  onCleanup(() => clearInterval(interval))
196
376
  })
@@ -334,18 +514,21 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
334
514
  >
335
515
  <Show when={!loading()}>
336
516
  <Show when={results().length > 0} fallback={<EmptyState query={query()} theme={theme()} />}>
337
- <For each={results()}>
338
- {(item, index) => (
339
- <ResultRow
340
- item={item}
341
- active={index() === selected()}
342
- width={leftWidth()}
343
- query={query()}
344
- theme={theme()}
345
- onMouseOver={() => setSelected(index())}
346
- onOpen={open}
347
- />
348
- )}
517
+ <For each={resultRenderWindow().items}>
518
+ {(item, index) => {
519
+ const absoluteIndex = () => resultRenderWindow().start + index()
520
+ return (
521
+ <ResultRow
522
+ item={item}
523
+ active={absoluteIndex() === selected()}
524
+ width={leftWidth()}
525
+ query={query()}
526
+ theme={theme()}
527
+ onMouseOver={() => setSelected(absoluteIndex())}
528
+ onOpen={open}
529
+ />
530
+ )
531
+ }}
349
532
  </For>
350
533
  <Show when={loadingMore()}>
351
534
  <SkeletonRow theme={theme()} />
package/ui/debug.ts CHANGED
@@ -1,6 +1,16 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs"
2
+ import path from "node:path"
3
+
1
4
  const enabled = process.env.OPENCODE_TELESCOPE_DEBUG === "1"
5
+ const logPath = process.env.OPENCODE_TELESCOPE_LOG
6
+ const consoleEnabled = !logPath || process.env.OPENCODE_TELESCOPE_CONSOLE === "1"
2
7
  const timers = new Map<string, number>()
3
8
 
9
+ if (enabled && logPath) {
10
+ mkdirSync(path.dirname(logPath), { recursive: true })
11
+ writeFileLog("session:start", { pid: process.pid })
12
+ }
13
+
4
14
  export const debug = {
5
15
  get enabled() {
6
16
  return enabled
@@ -14,12 +24,31 @@ export const debug = {
14
24
  if (!enabled) return
15
25
  const start = timers.get(label)
16
26
  if (start !== undefined) {
17
- console.log(`[telescope:time] ${label} ${(performance.now() - start).toFixed(2)}ms`)
27
+ writeLog("time", label, { ms: Number((performance.now() - start).toFixed(2)) })
18
28
  timers.delete(label)
19
29
  }
20
30
  },
21
31
 
22
32
  log(...args: unknown[]) {
23
- if (enabled) console.log("[telescope]", ...args)
33
+ if (enabled) writeLog("log", String(args[0] ?? "message"), args.length > 1 ? args.slice(1) : undefined)
24
34
  },
25
35
  }
36
+
37
+ function writeLog(type: "log" | "time", label: string, payload: unknown) {
38
+ if (logPath) writeFileLog(label, payload, type)
39
+ if (consoleEnabled) console.log(type === "time" ? `[telescope:time] ${label}` : "[telescope]", payload ?? "")
40
+ }
41
+
42
+ function writeFileLog(label: string, payload: unknown, type = "log") {
43
+ if (!logPath) return
44
+ appendFileSync(logPath, `${JSON.stringify({ ts: new Date().toISOString(), type, label, payload: safeJson(payload) })}\n`)
45
+ }
46
+
47
+ function safeJson(value: unknown) {
48
+ try {
49
+ JSON.stringify(value)
50
+ return value
51
+ } catch {
52
+ return String(value)
53
+ }
54
+ }