@bojackduy/opencode-telescope 0.1.13 → 0.1.15

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/telescope.tsx CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
3
3
  import type { InputRenderable, ParsedKey, ScrollBoxRenderable } from "@opentui/core"
4
4
  import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
5
- import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
5
+ import { For, Show, batch as solidBatch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
6
6
  import { ConversationPreview, PreviewHeader } from "./components/preview.tsx"
7
7
  import { EmptyState, ResultRow, SkeletonRow } from "./components/result-list.tsx"
8
8
  import {
@@ -14,43 +14,71 @@ import {
14
14
  searchSessionMessages,
15
15
  type ConversationPreviewPart,
16
16
  type SearchResult,
17
+ type SearchRole,
17
18
  } from "./search.ts"
18
19
  import { debug } from "./ui/debug.ts"
19
20
  import { syntaxStyle } from "./ui/format.ts"
20
- import { isKey, prevent } from "./ui/keyboard.ts"
21
+ import type { TelescopeConfig } from "./ui/config.ts"
22
+ import { inputSafeKeys, keyListLabel, matchesKey, prevent } from "./ui/keyboard.ts"
21
23
  import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
22
24
 
23
- export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) => {
25
+ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; onClose: () => void }) => {
26
+ type OwnerFilter = "all" | SearchRole
24
27
  const dimensions = useTerminalDimensions()
25
28
  const [query, setQuery] = createSignal("")
29
+ const [ownerFilter, setOwnerFilter] = createSignal<OwnerFilter>("all")
26
30
  const [results, setResults] = createSignal<SearchResult[]>([])
27
31
  const [previewParts, setPreviewParts] = createSignal<ConversationPreviewPart[]>([])
28
32
  const [selected, setSelected] = createSignal(0)
33
+ const [resultBaseOffset, setResultBaseOffset] = createSignal(0)
34
+ const [nextResultOffset, setNextResultOffset] = createSignal(0)
29
35
  const [busy, setBusy] = createSignal(false)
30
36
  const [error, setError] = createSignal("")
31
37
  const [mode, setMode] = createSignal<"normal" | "insert">("normal")
32
38
  const [loading, setLoading] = createSignal(true)
33
39
  const [hasMore, setHasMore] = createSignal(true)
34
40
  const [loadingMore, setLoadingMore] = createSignal(false)
41
+ const [loadingPreviousResults, setLoadingPreviousResults] = createSignal(false)
35
42
  const [prefetchingResults, setPrefetchingResults] = createSignal(false)
36
43
  const [resultPageInfo, setResultPageInfo] = createSignal({ loadedUntil: 0, hasMore: true, pageSize: 0, lastOffset: 0, lastAdded: 0 })
37
44
  const [hasMorePreviewBefore, setHasMorePreviewBefore] = createSignal(false)
38
45
  const [hasMorePreviewAfter, setHasMorePreviewAfter] = createSignal(false)
39
46
  const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
40
- const MIN_SEARCH_BATCH_SIZE = 25
41
- const MIN_RECENT_BATCH_SIZE = 15
47
+ const [prefetchingPreviewBefore, setPrefetchingPreviewBefore] = createSignal(false)
48
+ const [prefetchingPreviewAfter, setPrefetchingPreviewAfter] = createSignal(false)
49
+ const [previewEdgeLoadingReady, setPreviewEdgeLoadingReady] = createSignal(false)
50
+ const RESULT_BATCH_SIZE = 50
51
+ const RESULT_PREFETCH_AHEAD_ROWS = 25
42
52
  const RESULT_OVERSCAN_MULTIPLIER = 2
43
- const RESULT_PREFETCH_VIEWPORTS = 3
44
- const INITIAL_PREVIEW_BEFORE = 20
45
- const INITIAL_PREVIEW_AFTER = 30
53
+ const RESULT_CACHE_BEHIND_VIEWPORTS = 6
54
+ const INITIAL_PREVIEW_BEFORE = 6
55
+ const INITIAL_PREVIEW_AFTER = 12
56
+ const INITIAL_PREVIEW_DELAY_MS = 75
46
57
  const PREVIEW_PAGE_SIZE = 20
58
+ const PREVIEW_PREFETCH_VIEWPORTS = 0.5
47
59
  let input: InputRenderable | undefined
48
60
  let resultScroll: ScrollBoxRenderable | undefined
49
61
  let previewScroll: ScrollBoxRenderable | undefined
50
62
 
51
63
  const theme = createMemo(() => props.api.theme.current)
52
64
  const syntax = createMemo(() => syntaxStyle(theme()))
53
- const selectedResult = createMemo(() => results()[selected()])
65
+ const ownerRole = createMemo(() => ownerFilter() === "all" ? undefined : ownerFilter() as SearchRole)
66
+ const ownerLabel = createMemo(() => ownerFilter() === "user" ? "you" : ownerFilter())
67
+ const inputKeys = createMemo(() => ({
68
+ moveDown: inputSafeKeys(props.config.keys.moveDown),
69
+ moveUp: inputSafeKeys(props.config.keys.moveUp),
70
+ open: inputSafeKeys(props.config.keys.open),
71
+ normalMode: inputSafeKeys(props.config.keys.normalMode),
72
+ }))
73
+ const normalHelpItems = createMemo(() => [
74
+ `${keyListLabel(props.config.keys.moveUp)}/${keyListLabel(props.config.keys.moveDown)} move`,
75
+ `${keyListLabel(props.config.keys.scrollPreviewDown)}/${keyListLabel(props.config.keys.scrollPreviewUp)} scroll`,
76
+ `${keyListLabel(props.config.keys.toggleOwner)} owner`,
77
+ `${keyListLabel(props.config.keys.insertMode)} search`,
78
+ `${keyListLabel(props.config.keys.open)} open`,
79
+ `${keyListLabel(props.config.keys.close)} close`,
80
+ ])
81
+ const selectedResult = createMemo(() => results()[selected() - resultBaseOffset()])
54
82
  const popupWidth = createMemo(() => Math.max(72, Math.min(dimensions().width - 2, Math.floor(dimensions().width * 0.92))))
55
83
  const leftWidth = createMemo(() => Math.max(36, Math.min(64, Math.floor(popupWidth() * 0.36))))
56
84
  const height = createMemo(() => Math.max(18, dimensions().height - 8))
@@ -58,9 +86,29 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
58
86
  const dbPath = createMemo(() => resolveDatabasePath())
59
87
  const directory = props.api.state.path.directory
60
88
  let advanceSelectionAfterLoad = false
89
+ let advanceSelectionBeforeLoad = false
61
90
  let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
91
+ let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
92
+ let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
93
+ let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
94
+ let pendingPreviewBefore: { itemId: string; previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
95
+ let pendingPreviewAfter: { itemId: string; visibleLoad: boolean } | undefined
96
+ const cancelPreviewPrefetch = () => {
97
+ if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
98
+ if (previewAfterTimer) clearTimeout(previewAfterTimer)
99
+ previewBeforeTimer = undefined
100
+ previewAfterTimer = undefined
101
+ pendingPreviewBefore = undefined
102
+ pendingPreviewAfter = undefined
103
+ setPrefetchingPreviewBefore(false)
104
+ setPrefetchingPreviewAfter(false)
105
+ setLoadingPreviewMore(false)
106
+ setPreviewEdgeLoadingReady(false)
107
+ }
62
108
  onCleanup(() => {
63
109
  if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
110
+ if (resultPreviousTimer) clearTimeout(resultPreviousTimer)
111
+ cancelPreviewPrefetch()
64
112
  })
65
113
 
66
114
  const resultRowHeight = createMemo(() => leftWidth() >= 48 ? 3 : 4)
@@ -68,27 +116,47 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
68
116
  const viewportHeight = resultScroll?.height || Math.max(8, height() - 7)
69
117
  return Math.max(5, Math.floor(viewportHeight / resultRowHeight()))
70
118
  }
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())
119
+ const searchBatchSize = () => RESULT_BATCH_SIZE
120
+ const recentBatchSize = () => RESULT_BATCH_SIZE
121
+ const resultPrefetchThreshold = () => RESULT_PREFETCH_AHEAD_ROWS
122
+ const resultPrefetchState = () => {
123
+ const cachedEnd = resultBaseOffset() + results().length
124
+ const rowsAhead = Math.max(0, cachedEnd - selected() - 1)
125
+ const threshold = resultPrefetchThreshold()
126
+ return { cachedEnd, rowsAhead, threshold, shouldPrefetch: rowsAhead <= threshold }
127
+ }
128
+ const trimResultCache = (items: SearchResult[], anchorIndex: number) => {
129
+ const base = resultBaseOffset()
130
+ const keepBehind = visibleResultRows() * RESULT_CACHE_BEHIND_VIEWPORTS
131
+ const minOffset = Math.max(0, anchorIndex - keepBehind)
132
+ const drop = Math.min(Math.max(0, minOffset - base), Math.max(0, items.length - 1))
133
+ if (drop === 0) return { base, items }
134
+
135
+ const nextBase = base + drop
136
+ debug.log("results:evict", { fromBase: base, toBase: nextBase, dropped: drop, kept: items.length - drop, anchorIndex })
137
+ return { base: nextBase, items: items.slice(drop) }
138
+ }
74
139
  const resultRenderWindow = createMemo(() => {
75
- const total = results().length
140
+ const base = resultBaseOffset()
141
+ const cached = results()
76
142
  const visible = visibleResultRows()
77
143
  const overscan = visible * RESULT_OVERSCAN_MULTIPLIER
78
144
  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) }
145
+ const cachedStart = Math.max(0, index - overscan - base)
146
+ const cachedEnd = Math.min(cached.length, index + visible + overscan - base)
147
+ return { start: base + cachedStart, end: base + cachedEnd, items: cached.slice(cachedStart, cachedEnd) }
82
148
  })
83
149
 
84
150
  let lastResultRenderWindow = ""
85
151
  createEffect(() => {
86
152
  const window = resultRenderWindow()
87
- const key = `${window.start}:${window.end}:${results().length}`
153
+ const key = `${window.start}:${window.end}:${resultBaseOffset()}:${nextResultOffset()}:${results().length}`
88
154
  if (key === lastResultRenderWindow) return
89
155
  lastResultRenderWindow = key
90
156
  debug.log("results:render-window", {
91
157
  selected: selected(),
158
+ baseOffset: resultBaseOffset(),
159
+ nextOffset: nextResultOffset(),
92
160
  totalLoaded: results().length,
93
161
  start: window.start,
94
162
  end: window.end,
@@ -101,8 +169,18 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
101
169
 
102
170
  createEffect(() => {
103
171
  const q = query().trim()
172
+ const role = ownerRole()
104
173
  setError("")
105
174
  setHasMore(true)
175
+ if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
176
+ if (resultPreviousTimer) clearTimeout(resultPreviousTimer)
177
+ resultPrefetchTimer = undefined
178
+ resultPreviousTimer = undefined
179
+ advanceSelectionAfterLoad = false
180
+ advanceSelectionBeforeLoad = false
181
+ setLoadingMore(false)
182
+ setLoadingPreviousResults(false)
183
+ setPrefetchingResults(false)
106
184
  const db = dbPath()
107
185
  const dir = directory
108
186
 
@@ -112,14 +190,22 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
112
190
  const timer = setTimeout(() => {
113
191
  debug.time("query:recent")
114
192
  try {
115
- const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir })
116
- setResults(batch)
117
- setHasMore(batch.length >= limit)
118
- setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
119
- setSelected(0)
193
+ const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
194
+ solidBatch(() => {
195
+ setResults(batch)
196
+ setResultBaseOffset(0)
197
+ setNextResultOffset(batch.length)
198
+ setHasMore(batch.length >= limit)
199
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
200
+ setSelected(0)
201
+ })
120
202
  } catch (err) {
121
- setResults([])
122
- setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
203
+ solidBatch(() => {
204
+ setResults([])
205
+ setResultBaseOffset(0)
206
+ setNextResultOffset(0)
207
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
208
+ })
123
209
  setError(err instanceof Error ? err.message : String(err))
124
210
  }
125
211
  debug.timeEnd("query:recent")
@@ -135,14 +221,22 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
135
221
  const timer = setTimeout(() => {
136
222
  debug.time("query:search")
137
223
  try {
138
- const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir })
139
- setResults(batch)
140
- setHasMore(batch.length >= limit)
141
- setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
142
- setSelected(0)
224
+ const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
225
+ solidBatch(() => {
226
+ setResults(batch)
227
+ setResultBaseOffset(0)
228
+ setNextResultOffset(batch.length)
229
+ setHasMore(batch.length >= limit)
230
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
231
+ setSelected(0)
232
+ })
143
233
  } catch (err) {
144
- setResults([])
145
- setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
234
+ solidBatch(() => {
235
+ setResults([])
236
+ setResultBaseOffset(0)
237
+ setNextResultOffset(0)
238
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
239
+ })
146
240
  setError(err instanceof Error ? err.message : String(err))
147
241
  } finally {
148
242
  debug.timeEnd("query:search")
@@ -159,10 +253,11 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
159
253
  advanceSelectionAfterLoad = false
160
254
  return
161
255
  }
162
- if (loadingMore() || prefetchingResults() || busy() || loading()) return
256
+ if (loadingMore() || loadingPreviousResults() || prefetchingResults() || busy() || loading()) return
163
257
 
164
- const total = results().length
258
+ const offset = nextResultOffset()
165
259
  const q = query().trim()
260
+ const role = ownerRole()
166
261
  const db = dbPath()
167
262
  const dir = directory
168
263
  const limit = q ? searchBatchSize() : recentBatchSize()
@@ -171,18 +266,35 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
171
266
  debug.time("query:load-more")
172
267
  try {
173
268
  const batch = q
174
- ? searchSessionMessages(q, { limit, offset: total, dbPath: db, directory: dir })
175
- : recentSessionMessages({ limit, offset: total, dbPath: db, directory: dir })
269
+ ? searchSessionMessages(q, { limit, offset, dbPath: db, directory: dir, role })
270
+ : recentSessionMessages({ limit, offset, dbPath: db, directory: dir, role })
176
271
  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 })
272
+ const nextLoadedUntil = offset + batch.length
273
+ const previousSelected = selected()
274
+ const nextSelected = advanceSelectionAfterLoad && batch.length > 0 ? offset : selected()
275
+ const nextCache = trimResultCache([...results(), ...batch], nextSelected)
276
+ debug.log("results:prefetch", {
277
+ offset,
278
+ limit,
279
+ added: batch.length,
280
+ baseOffset: nextCache.base,
281
+ cached: nextCache.items.length,
282
+ totalLoaded: nextLoadedUntil,
283
+ hasMore: nextHasMore,
284
+ advance,
285
+ })
286
+ const shouldAdvance = advanceSelectionAfterLoad
287
+ solidBatch(() => {
288
+ setResultBaseOffset(nextCache.base)
289
+ setNextResultOffset(nextLoadedUntil)
290
+ setResults(nextCache.items)
291
+ setResultPageInfo({ loadedUntil: nextLoadedUntil, hasMore: nextHasMore, pageSize: limit, lastOffset: offset, lastAdded: batch.length })
292
+ if (!nextHasMore) setHasMore(false)
293
+ if (shouldAdvance && batch.length > 0) setSelected(offset)
294
+ })
295
+ if (shouldAdvance) {
296
+ debug.log("results:advance-after-load", { from: previousSelected, offset, added: batch.length })
184
297
  advanceSelectionAfterLoad = false
185
- if (batch.length > 0) setSelected(total)
186
298
  }
187
299
  } catch (err) {
188
300
  debug.log("results:load-more:error", err instanceof Error ? err.message : String(err))
@@ -193,9 +305,51 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
193
305
  }
194
306
  }
195
307
 
308
+ const loadPreviousResults = (advance = false) => {
309
+ if (advance) advanceSelectionBeforeLoad = true
310
+ const base = resultBaseOffset()
311
+ if (base <= 0) {
312
+ advanceSelectionBeforeLoad = false
313
+ return
314
+ }
315
+ if (loadingMore() || loadingPreviousResults() || prefetchingResults() || busy() || loading()) return
316
+
317
+ const q = query().trim()
318
+ const role = ownerRole()
319
+ const db = dbPath()
320
+ const dir = directory
321
+ const pageSize = q ? searchBatchSize() : recentBatchSize()
322
+ const offset = Math.max(0, base - pageSize)
323
+ const limit = base - offset
324
+
325
+ setLoadingPreviousResults(true)
326
+ debug.time("query:load-before")
327
+ try {
328
+ const batch = q
329
+ ? searchSessionMessages(q, { limit, offset, dbPath: db, directory: dir, role })
330
+ : recentSessionMessages({ limit, offset, dbPath: db, directory: dir, role })
331
+ const nextSelected = advanceSelectionBeforeLoad && batch.length > 0 ? base - 1 : selected()
332
+ debug.log("results:load-before", { offset, limit, added: batch.length, fromBase: base, toBase: offset, cached: results().length + batch.length, advance })
333
+ const shouldAdvance = advanceSelectionBeforeLoad
334
+ solidBatch(() => {
335
+ setResultBaseOffset(offset)
336
+ setResults([...batch, ...results()])
337
+ setResultPageInfo({ loadedUntil: nextResultOffset(), hasMore: hasMore(), pageSize, lastOffset: offset, lastAdded: batch.length })
338
+ if (shouldAdvance && batch.length > 0) setSelected(nextSelected)
339
+ })
340
+ if (shouldAdvance) advanceSelectionBeforeLoad = false
341
+ } catch (err) {
342
+ debug.log("results:load-before:error", err instanceof Error ? err.message : String(err))
343
+ advanceSelectionBeforeLoad = false
344
+ } finally {
345
+ debug.timeEnd("query:load-before")
346
+ setLoadingPreviousResults(false)
347
+ }
348
+ }
349
+
196
350
  const scheduleResultPrefetch = (advance = false) => {
197
351
  if (advance) advanceSelectionAfterLoad = true
198
- if (resultPrefetchTimer || loadingMore() || prefetchingResults()) return
352
+ if (resultPrefetchTimer || loadingMore() || loadingPreviousResults() || prefetchingResults()) return
199
353
  debug.log("results:prefetch-scheduled", { advance, pendingAdvance: advanceSelectionAfterLoad, pageInfo: resultPageInfo() })
200
354
  resultPrefetchTimer = setTimeout(() => {
201
355
  resultPrefetchTimer = undefined
@@ -203,15 +357,33 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
203
357
  }, 1)
204
358
  }
205
359
 
360
+ const schedulePreviousResultsLoad = (advance = false) => {
361
+ if (advance) advanceSelectionBeforeLoad = true
362
+ if (resultPreviousTimer || loadingMore() || loadingPreviousResults() || prefetchingResults()) return
363
+ debug.log("results:load-before-scheduled", { advance, pendingAdvance: advanceSelectionBeforeLoad, baseOffset: resultBaseOffset(), pageInfo: resultPageInfo() })
364
+ resultPreviousTimer = setTimeout(() => {
365
+ resultPreviousTimer = undefined
366
+ loadPreviousResults(advanceSelectionBeforeLoad)
367
+ }, 1)
368
+ }
369
+
370
+ let lastResultPrefetchDecision = ""
371
+
206
372
  const move = (delta: number) => {
207
373
  if (results().length === 0) return
208
374
  setSelected((index) => {
375
+ const base = resultBaseOffset()
376
+ const cachedEnd = base + results().length
209
377
  const next = index + delta
210
378
  let finalIndex = next
211
- if (next < 0) finalIndex = results().length - 1
212
- else if (next >= results().length) {
379
+ if (next < 0) finalIndex = cachedEnd - 1
380
+ else if (next < base) {
381
+ schedulePreviousResultsLoad(true)
382
+ finalIndex = base
383
+ }
384
+ else if (next >= cachedEnd) {
213
385
  if (hasMore()) scheduleResultPrefetch(true)
214
- finalIndex = results().length - 1
386
+ finalIndex = cachedEnd - 1
215
387
  }
216
388
 
217
389
  if (finalIndex !== index) debug.time("nav:total")
@@ -235,11 +407,26 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
235
407
  })
236
408
 
237
409
  createEffect(() => {
238
- const index = selected()
239
- const total = results().length
240
- const threshold = resultPrefetchThreshold()
241
- if (index < total - threshold) return
242
- if (!hasMore() || loadingMore() || prefetchingResults() || busy() || loading()) return
410
+ const state = resultPrefetchState()
411
+ const blockedBy = !hasMore()
412
+ ? "no-more"
413
+ : loadingMore()
414
+ ? "loading-more"
415
+ : loadingPreviousResults()
416
+ ? "loading-before"
417
+ : prefetchingResults()
418
+ ? "prefetching"
419
+ : busy()
420
+ ? "busy"
421
+ : loading()
422
+ ? "loading"
423
+ : ""
424
+ const decisionKey = `${selected()}:${state.cachedEnd}:${state.rowsAhead}:${state.threshold}:${blockedBy}:${state.shouldPrefetch}`
425
+ if (decisionKey !== lastResultPrefetchDecision) {
426
+ lastResultPrefetchDecision = decisionKey
427
+ debug.log("results:prefetch-decision", { selected: selected(), ...state, blockedBy: blockedBy || undefined })
428
+ }
429
+ if (!state.shouldPrefetch || blockedBy) return
243
430
 
244
431
  const timer = setTimeout(() => scheduleResultPrefetch(false), 100)
245
432
  onCleanup(() => clearTimeout(timer))
@@ -251,19 +438,26 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
251
438
  return lastChild ? lastChild.y + lastChild.height : 0
252
439
  }
253
440
 
254
- const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true) => {
441
+ const resetPreviewScroll = () => {
442
+ if (!previewScroll || previewScroll.y === 0) return
443
+ previewScroll.scrollBy(-previewScroll.y)
444
+ }
445
+
446
+ const loadPreviewBefore = (itemId: string, previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
255
447
  const item = selectedResult()
256
448
  const first = previewParts()[0]
257
- if (!item || !first || loadingPreviewMore()) return
258
- setLoadingPreviewMore(true)
449
+ if (!item || item.id !== itemId || !first || loadingPreviewMore() || prefetchingPreviewBefore()) return
450
+ visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
259
451
  debug.time("preview:load-before")
260
452
  try {
261
453
  const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
454
+ if (selectedResult()?.id !== itemId) return
262
455
  debug.log("preview:load-before", {
263
456
  item: item.id,
264
457
  added: page.parts.length,
265
458
  hasMoreBefore: page.hasMoreBefore,
266
459
  preserveScroll,
460
+ visibleLoad,
267
461
  first: page.parts[0]?.id,
268
462
  last: page.parts.at(-1)?.id,
269
463
  })
@@ -271,6 +465,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
271
465
  setPreviewParts((prev) => [...page.parts, ...prev])
272
466
  if (preserveScroll) {
273
467
  setTimeout(() => {
468
+ if (selectedResult()?.id !== itemId) return
274
469
  const delta = previewContentHeight() - previousContentHeight
275
470
  if (delta > 0) previewScroll?.scrollBy(delta)
276
471
  }, 1)
@@ -281,22 +476,24 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
281
476
  debug.log("preview:load-before:error", err instanceof Error ? err.message : String(err))
282
477
  } finally {
283
478
  debug.timeEnd("preview:load-before")
284
- setLoadingPreviewMore(false)
479
+ visibleLoad ? setLoadingPreviewMore(false) : setPrefetchingPreviewBefore(false)
285
480
  }
286
481
  }
287
482
 
288
- const loadPreviewAfter = () => {
483
+ const loadPreviewAfter = (itemId: string, visibleLoad = false) => {
289
484
  const item = selectedResult()
290
485
  const last = previewParts().at(-1)
291
- if (!item || !last || loadingPreviewMore()) return
292
- setLoadingPreviewMore(true)
486
+ if (!item || item.id !== itemId || !last || loadingPreviewMore() || prefetchingPreviewAfter()) return
487
+ visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewAfter(true)
293
488
  debug.time("preview:load-after")
294
489
  try {
295
490
  const page = loadConversationAfter(item, { id: last.id, timeCreated: last.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
491
+ if (selectedResult()?.id !== itemId) return
296
492
  debug.log("preview:load-after", {
297
493
  item: item.id,
298
494
  added: page.parts.length,
299
495
  hasMoreAfter: page.hasMoreAfter,
496
+ visibleLoad,
300
497
  first: page.parts[0]?.id,
301
498
  last: page.parts.at(-1)?.id,
302
499
  })
@@ -306,14 +503,49 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
306
503
  debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
307
504
  } finally {
308
505
  debug.timeEnd("preview:load-after")
309
- setLoadingPreviewMore(false)
506
+ visibleLoad ? setLoadingPreviewMore(false) : setPrefetchingPreviewAfter(false)
507
+ }
508
+ }
509
+
510
+ const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
511
+ const item = selectedResult()
512
+ if (!item || !hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) return
513
+ if (previewBeforeTimer) {
514
+ if (pendingPreviewBefore) {
515
+ pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
516
+ pendingPreviewBefore.visibleLoad = pendingPreviewBefore.visibleLoad || visibleLoad
517
+ }
518
+ return
310
519
  }
520
+ pendingPreviewBefore = { itemId: item.id, previousContentHeight, preserveScroll, visibleLoad }
521
+ debug.log("preview:prefetch-before-scheduled", { item: item.id, preserveScroll, visibleLoad })
522
+ previewBeforeTimer = setTimeout(() => {
523
+ const pending = pendingPreviewBefore
524
+ previewBeforeTimer = undefined
525
+ pendingPreviewBefore = undefined
526
+ if (pending && selectedResult()?.id === pending.itemId) loadPreviewBefore(pending.itemId, pending.previousContentHeight, pending.preserveScroll, pending.visibleLoad)
527
+ }, 1)
528
+ }
529
+
530
+ const schedulePreviewAfter = (visibleLoad = false) => {
531
+ const item = selectedResult()
532
+ if (!item || !hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) return
533
+ pendingPreviewAfter = { itemId: item.id, visibleLoad: (pendingPreviewAfter?.visibleLoad || visibleLoad) }
534
+ if (previewAfterTimer) return
535
+ debug.log("preview:prefetch-after-scheduled", { item: item.id, visibleLoad })
536
+ previewAfterTimer = setTimeout(() => {
537
+ const pending = pendingPreviewAfter
538
+ previewAfterTimer = undefined
539
+ pendingPreviewAfter = undefined
540
+ if (pending && selectedResult()?.id === pending.itemId) loadPreviewAfter(pending.itemId, pending.visibleLoad)
541
+ }, 1)
311
542
  }
312
543
 
313
544
  let lastPreviewItemId = ""
314
545
  createEffect(() => {
315
546
  const item = selectedResult()
316
547
  if (!item) {
548
+ cancelPreviewPrefetch()
317
549
  setPreviewParts([])
318
550
  setHasMorePreviewBefore(false)
319
551
  setHasMorePreviewAfter(false)
@@ -321,56 +553,92 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
321
553
  }
322
554
  if (item.id === lastPreviewItemId) return
323
555
  lastPreviewItemId = item.id
556
+ cancelPreviewPrefetch()
557
+ resetPreviewScroll()
558
+ solidBatch(() => {
559
+ setPreviewParts([])
560
+ setHasMorePreviewBefore(false)
561
+ setHasMorePreviewAfter(false)
562
+ setPreviewEdgeLoadingReady(false)
563
+ })
324
564
  debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
325
565
  const db = dbPath()
326
- debug.time("preview:load")
327
- try {
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)
341
- } catch {}
566
+ const itemId = item.id
567
+ debug.log("preview:schedule", { item: item.id, delayMs: INITIAL_PREVIEW_DELAY_MS, before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
342
568
  debug.timeEnd("nav:total")
343
- debug.timeEnd("preview:load")
569
+ const timer = setTimeout(() => {
570
+ if (selectedResult()?.id !== itemId) {
571
+ debug.log("preview:load:cancelled", { item: itemId })
572
+ return
573
+ }
574
+ debug.time("preview:load")
575
+ try {
576
+ const page = loadConversationAround(item, { before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER, dbPath: db })
577
+ if (selectedResult()?.id !== itemId) {
578
+ debug.log("preview:load:cancelled", { item: itemId, afterLoad: true })
579
+ return
580
+ }
581
+ debug.log("preview:init", {
582
+ item: item.id,
583
+ session: item.sessionID,
584
+ parts: page.parts.length,
585
+ hasMoreBefore: page.hasMoreBefore,
586
+ hasMoreAfter: page.hasMoreAfter,
587
+ first: page.parts[0]?.id,
588
+ last: page.parts.at(-1)?.id,
589
+ })
590
+ solidBatch(() => {
591
+ setPreviewParts(page.parts)
592
+ setHasMorePreviewBefore(page.hasMoreBefore)
593
+ setHasMorePreviewAfter(page.hasMoreAfter)
594
+ })
595
+ } catch {
596
+ } finally {
597
+ debug.timeEnd("preview:load")
598
+ }
599
+ }, INITIAL_PREVIEW_DELAY_MS)
600
+ onCleanup(() => {
601
+ clearTimeout(timer)
602
+ if (selectedResult()?.id !== itemId) {
603
+ debug.log("preview:load:cancelled", { item: itemId, cleanup: true })
604
+ }
605
+ })
344
606
  })
345
607
 
346
608
  createEffect(() => {
347
609
  const item = selectedResult()
348
610
  if (!item) return
349
611
  const interval = setInterval(() => {
350
- if (loadingPreviewMore()) return
612
+ if (!previewEdgeLoadingReady() || loadingPreviewMore() || prefetchingPreviewBefore() || prefetchingPreviewAfter()) return
351
613
  const scroll = previewScroll
352
614
  const children = scroll?.getChildren()
353
615
  if (!scroll || !children || children.length === 0) return
354
616
  const lastChild = children[children.length - 1] as { y: number; height: number }
355
617
  const totalContentHeight = lastChild.y + lastChild.height
356
618
  const atTop = scroll.y <= 0
357
- const nearTop = scroll.y <= 2
619
+ const prefetchDistance = Math.max(2, Math.floor(scroll.height * PREVIEW_PREFETCH_VIEWPORTS))
620
+ const nearTop = scroll.y <= prefetchDistance
358
621
  const atBottom = scroll.y + scroll.height >= totalContentHeight - 1
359
- if (nearTop || atBottom) {
622
+ const nearBottom = scroll.y + scroll.height >= totalContentHeight - prefetchDistance
623
+ if (nearTop || nearBottom) {
360
624
  debug.log("preview:scroll-edge", {
361
625
  y: scroll.y,
362
626
  height: scroll.height,
363
627
  contentHeight: totalContentHeight,
628
+ prefetchDistance,
364
629
  atTop,
365
630
  nearTop,
366
631
  atBottom,
632
+ nearBottom,
367
633
  hasMoreBefore: hasMorePreviewBefore(),
368
634
  hasMoreAfter: hasMorePreviewAfter(),
635
+ prefetchingBefore: prefetchingPreviewBefore(),
636
+ prefetchingAfter: prefetchingPreviewAfter(),
369
637
  children: children.length,
370
638
  })
371
639
  }
372
- if (nearTop && hasMorePreviewBefore()) loadPreviewBefore(totalContentHeight, !atTop)
373
- if (atBottom && hasMorePreviewAfter()) loadPreviewAfter()
640
+ if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, false, true)
641
+ if (nearBottom && hasMorePreviewAfter()) schedulePreviewAfter(atBottom)
374
642
  }, 400)
375
643
  onCleanup(() => clearInterval(interval))
376
644
  })
@@ -382,8 +650,19 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
382
650
  if (!item) return
383
651
  if (item.id === scrolledItem) return
384
652
  scrolledItem = item.id
385
- const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
386
- onCleanup(() => clearTimeout(timer))
653
+ const itemId = item.id
654
+ let readyTimer: ReturnType<typeof setTimeout> | undefined
655
+ const timer = setTimeout(() => {
656
+ if (selectedResult()?.id !== itemId) return
657
+ scrollPreviewToTarget(previewScroll, messageTargetID(item))
658
+ readyTimer = setTimeout(() => {
659
+ if (selectedResult()?.id === itemId) setPreviewEdgeLoadingReady(true)
660
+ }, 100)
661
+ }, 1)
662
+ onCleanup(() => {
663
+ clearTimeout(timer)
664
+ if (readyTimer) clearTimeout(readyTimer)
665
+ })
387
666
  })
388
667
 
389
668
  const open = () => {
@@ -409,50 +688,54 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
409
688
  el?.blur?.()
410
689
  }
411
690
 
691
+ const toggleOwnerFilter = () => {
692
+ setOwnerFilter((filter) => filter === "all" ? "user" : filter === "user" ? "assistant" : "all")
693
+ }
694
+
412
695
  useKeyboard((evt) => {
413
696
  if (!props.api.ui.dialog.open) return
414
697
 
415
- if (isKey(evt, "down") || isKey(evt, "up")) {
698
+ if (mode() !== "normal") return
699
+
700
+ if (matchesKey(evt, props.config.keys.moveDown) || matchesKey(evt, props.config.keys.moveUp)) {
416
701
  prevent(evt)
417
- isKey(evt, "down") ? move(1) : move(-1)
702
+ matchesKey(evt, props.config.keys.moveDown) ? move(1) : move(-1)
418
703
  return
419
704
  }
420
705
 
421
- if (mode() === "normal") {
422
- if (isKey(evt, "j")) {
423
- prevent(evt)
424
- move(1)
425
- return
426
- }
427
- if (isKey(evt, "k")) {
428
- prevent(evt)
429
- move(-1)
430
- return
431
- }
432
- if (isKey(evt, "q")) {
433
- prevent(evt)
434
- props.onClose()
435
- return
436
- }
437
- if (isKey(evt, "d")) {
438
- scrollPreview(1, evt)
439
- return
440
- }
441
- if (isKey(evt, "u")) {
442
- scrollPreview(-1, evt)
443
- return
444
- }
445
- if (isKey(evt, "/")) {
446
- prevent(evt)
447
- setMode("insert")
448
- focusInput()
449
- return
450
- }
451
- if (isKey(evt, "enter", "return")) {
452
- prevent(evt)
453
- open()
454
- return
455
- }
706
+ if (matchesKey(evt, props.config.keys.open)) {
707
+ prevent(evt)
708
+ open()
709
+ return
710
+ }
711
+
712
+ if (matchesKey(evt, props.config.keys.close)) {
713
+ prevent(evt)
714
+ props.onClose()
715
+ return
716
+ }
717
+
718
+ if (matchesKey(evt, props.config.keys.scrollPreviewDown)) {
719
+ scrollPreview(1, evt)
720
+ return
721
+ }
722
+
723
+ if (matchesKey(evt, props.config.keys.scrollPreviewUp)) {
724
+ scrollPreview(-1, evt)
725
+ return
726
+ }
727
+
728
+ if (matchesKey(evt, props.config.keys.toggleOwner)) {
729
+ prevent(evt)
730
+ toggleOwnerFilter()
731
+ return
732
+ }
733
+
734
+ if (matchesKey(evt, props.config.keys.insertMode)) {
735
+ prevent(evt)
736
+ setMode("insert")
737
+ focusInput()
738
+ return
456
739
  }
457
740
  })
458
741
 
@@ -483,12 +766,17 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
483
766
  focusedBackgroundColor={theme().backgroundPanel}
484
767
  onInput={(value) => setQuery(value)}
485
768
  onKeyDown={(evt: ParsedKey) => {
486
- if (isKey(evt, "down") || isKey(evt, "up")) {
769
+ if (matchesKey(evt, inputKeys().moveDown) || matchesKey(evt, inputKeys().moveUp)) {
487
770
  prevent(evt)
488
- isKey(evt, "down") ? move(1) : move(-1)
771
+ matchesKey(evt, inputKeys().moveDown) ? move(1) : move(-1)
489
772
  return
490
773
  }
491
- if (evt.ctrl && isKey(evt, "q")) {
774
+ if (matchesKey(evt, inputKeys().open)) {
775
+ prevent(evt)
776
+ open()
777
+ return
778
+ }
779
+ if (matchesKey(evt, inputKeys().normalMode)) {
492
780
  prevent(evt)
493
781
  setMode("normal")
494
782
  blurInput()
@@ -496,13 +784,13 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
496
784
  }}
497
785
  flexGrow={1}
498
786
  />
499
- <text fg={theme().textMuted}>{busy() ? "searching" : loading() ? "loading..." : query().trim() ? (results().length > 0 ? `${selected() + 1}/${results().length} hits` : "0 hits") : (results().length > 0 ? `${selected() + 1}/${results().length} recent` : "0 recent")}</text>
787
+ <text fg={theme().textMuted}>{busy() ? `searching ${ownerLabel()}` : loading() ? `loading ${ownerLabel()}` : query().trim() ? (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} hits` : `${ownerLabel()} 0 hits`) : (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} recent` : `${ownerLabel()} 0 recent`)}</text>
500
788
  </box>
501
789
  </box>
502
790
 
503
791
  <box flexDirection="row" flexGrow={1} minHeight={0}>
504
792
  <box width={leftWidth()} flexDirection="column" minHeight={0} backgroundColor={theme().backgroundPanel}>
505
- <scrollbox ref={(element: ScrollBoxRenderable) => (resultScroll = element)} flexGrow={1} minHeight={0} verticalScrollbarOptions={{ visible: false }}>
793
+ <scrollbox ref={(element: ScrollBoxRenderable) => (resultScroll = element)} flexGrow={1} minHeight={0} verticalScrollbarOptions={{ visible: true }}>
506
794
  <Show
507
795
  when={!error()}
508
796
  fallback={
@@ -513,7 +801,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
513
801
  }
514
802
  >
515
803
  <Show when={!loading()}>
516
- <Show when={results().length > 0} fallback={<EmptyState query={query()} theme={theme()} />}>
804
+ <Show when={results().length > 0} fallback={<EmptyState query={query()} owner={ownerLabel()} theme={theme()} />}>
517
805
  <For each={resultRenderWindow().items}>
518
806
  {(item, index) => {
519
807
  const absoluteIndex = () => resultRenderWindow().start + index()
@@ -565,23 +853,21 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
565
853
  <Show when={mode() === "normal"}>
566
854
  <box paddingLeft={4} paddingRight={4} flexDirection="row" backgroundColor={theme().backgroundElement} gap={2}>
567
855
  <text fg={theme().accent}><span style={{ bold: true }}>NORMAL</span></text>
568
- <text fg={theme().textMuted}>·</text>
569
- <text fg={theme().text}>j/k move</text>
570
- <text fg={theme().textMuted}>·</text>
571
- <text fg={theme().textMuted}>d/u scroll</text>
572
- <text fg={theme().textMuted}>·</text>
573
- <text fg={theme().text}>/ search</text>
574
- <text fg={theme().textMuted}>·</text>
575
- <text fg={theme().textMuted}>enter open</text>
576
- <text fg={theme().textMuted}>·</text>
577
- <text fg={theme().text}>q close</text>
856
+ <For each={normalHelpItems()}>
857
+ {(item, index) => (
858
+ <>
859
+ <text fg={theme().textMuted}>·</text>
860
+ <text fg={index() % 2 === 0 ? theme().text : theme().textMuted}>{item}</text>
861
+ </>
862
+ )}
863
+ </For>
578
864
  </box>
579
865
  </Show>
580
866
  <Show when={mode() === "insert"}>
581
867
  <box paddingLeft={4} paddingRight={4} flexDirection="row" backgroundColor={theme().backgroundElement} gap={2}>
582
868
  <text fg={theme().warning}><span style={{ bold: true }}>INSERT</span></text>
583
869
  <text fg={theme().textMuted}>·</text>
584
- <text fg={theme().textMuted}>↑/↓ move · ^Q normal</text>
870
+ <text fg={theme().textMuted}>{keyListLabel(inputKeys().moveUp)}/{keyListLabel(inputKeys().moveDown)} move · {keyListLabel(inputKeys().normalMode)} normal</text>
585
871
  </box>
586
872
  </Show>
587
873