@bojackduy/opencode-telescope 0.1.12 → 0.1.14

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,72 +2,209 @@
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 {
9
- loadConversationWindow,
9
+ loadConversationAfter,
10
+ loadConversationAround,
11
+ loadConversationBefore,
10
12
  recentSessionMessages,
11
13
  resolveDatabasePath,
12
14
  searchSessionMessages,
13
15
  type ConversationPreviewPart,
14
16
  type SearchResult,
17
+ type SearchRole,
15
18
  } from "./search.ts"
16
19
  import { debug } from "./ui/debug.ts"
17
20
  import { syntaxStyle } from "./ui/format.ts"
18
- 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"
19
23
  import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
20
24
 
21
- export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) => {
25
+ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; onClose: () => void }) => {
26
+ type OwnerFilter = "all" | SearchRole
22
27
  const dimensions = useTerminalDimensions()
23
28
  const [query, setQuery] = createSignal("")
29
+ const [ownerFilter, setOwnerFilter] = createSignal<OwnerFilter>("all")
24
30
  const [results, setResults] = createSignal<SearchResult[]>([])
25
31
  const [previewParts, setPreviewParts] = createSignal<ConversationPreviewPart[]>([])
26
32
  const [selected, setSelected] = createSignal(0)
33
+ const [resultBaseOffset, setResultBaseOffset] = createSignal(0)
34
+ const [nextResultOffset, setNextResultOffset] = createSignal(0)
27
35
  const [busy, setBusy] = createSignal(false)
28
36
  const [error, setError] = createSignal("")
29
37
  const [mode, setMode] = createSignal<"normal" | "insert">("normal")
30
38
  const [loading, setLoading] = createSignal(true)
31
39
  const [hasMore, setHasMore] = createSignal(true)
32
40
  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 })
41
+ const [loadingPreviousResults, setLoadingPreviousResults] = createSignal(false)
42
+ const [prefetchingResults, setPrefetchingResults] = createSignal(false)
43
+ const [resultPageInfo, setResultPageInfo] = createSignal({ loadedUntil: 0, hasMore: true, pageSize: 0, lastOffset: 0, lastAdded: 0 })
44
+ const [hasMorePreviewBefore, setHasMorePreviewBefore] = createSignal(false)
45
+ const [hasMorePreviewAfter, setHasMorePreviewAfter] = createSignal(false)
46
+ const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
47
+ const [prefetchingPreviewBefore, setPrefetchingPreviewBefore] = createSignal(false)
48
+ const [prefetchingPreviewAfter, setPrefetchingPreviewAfter] = createSignal(false)
49
+ const MIN_SEARCH_BATCH_SIZE = 25
50
+ const MIN_RECENT_BATCH_SIZE = 15
51
+ const RESULT_OVERSCAN_MULTIPLIER = 2
52
+ const RESULT_BATCH_VIEWPORTS = 4
53
+ const RESULT_PREFETCH_VIEWPORTS = 10
54
+ const RESULT_CACHE_BEHIND_VIEWPORTS = 6
55
+ const INITIAL_PREVIEW_BEFORE = 20
56
+ const INITIAL_PREVIEW_AFTER = 30
57
+ const PREVIEW_PAGE_SIZE = 20
58
+ const PREVIEW_PREFETCH_VIEWPORTS = 0.5
39
59
  let input: InputRenderable | undefined
40
60
  let resultScroll: ScrollBoxRenderable | undefined
41
61
  let previewScroll: ScrollBoxRenderable | undefined
42
62
 
43
63
  const theme = createMemo(() => props.api.theme.current)
44
64
  const syntax = createMemo(() => syntaxStyle(theme()))
45
- 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()])
46
82
  const popupWidth = createMemo(() => Math.max(72, Math.min(dimensions().width - 2, Math.floor(dimensions().width * 0.92))))
47
83
  const leftWidth = createMemo(() => Math.max(36, Math.min(64, Math.floor(popupWidth() * 0.36))))
48
84
  const height = createMemo(() => Math.max(18, dimensions().height - 8))
49
85
  const verticalOffset = createMemo(() => Math.floor(dimensions().height / 4 - height() / 2) - 2)
50
86
  const dbPath = createMemo(() => resolveDatabasePath())
51
87
  const directory = props.api.state.path.directory
88
+ let advanceSelectionAfterLoad = false
89
+ let advanceSelectionBeforeLoad = false
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: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
95
+ let pendingPreviewAfterVisible = false
96
+ const cancelPreviewPrefetch = () => {
97
+ if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
98
+ if (previewAfterTimer) clearTimeout(previewAfterTimer)
99
+ previewBeforeTimer = undefined
100
+ previewAfterTimer = undefined
101
+ pendingPreviewBefore = undefined
102
+ pendingPreviewAfterVisible = false
103
+ setPrefetchingPreviewBefore(false)
104
+ setPrefetchingPreviewAfter(false)
105
+ setLoadingPreviewMore(false)
106
+ }
107
+ onCleanup(() => {
108
+ if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
109
+ if (resultPreviousTimer) clearTimeout(resultPreviousTimer)
110
+ cancelPreviewPrefetch()
111
+ })
112
+
113
+ const resultRowHeight = createMemo(() => leftWidth() >= 48 ? 3 : 4)
114
+ const visibleResultRows = () => {
115
+ const viewportHeight = resultScroll?.height || Math.max(8, height() - 7)
116
+ return Math.max(5, Math.floor(viewportHeight / resultRowHeight()))
117
+ }
118
+ const searchBatchSize = () => Math.max(MIN_SEARCH_BATCH_SIZE, visibleResultRows() * RESULT_BATCH_VIEWPORTS)
119
+ const recentBatchSize = () => Math.max(MIN_RECENT_BATCH_SIZE, visibleResultRows() * RESULT_BATCH_VIEWPORTS)
120
+ const resultPrefetchThreshold = () => visibleResultRows() * RESULT_PREFETCH_VIEWPORTS
121
+ const resultPrefetchState = () => {
122
+ const cachedEnd = resultBaseOffset() + results().length
123
+ const rowsAhead = Math.max(0, cachedEnd - selected() - 1)
124
+ const threshold = resultPrefetchThreshold()
125
+ return { cachedEnd, rowsAhead, threshold, shouldPrefetch: rowsAhead <= threshold }
126
+ }
127
+ const trimResultCache = (items: SearchResult[], anchorIndex: number) => {
128
+ const base = resultBaseOffset()
129
+ const keepBehind = visibleResultRows() * RESULT_CACHE_BEHIND_VIEWPORTS
130
+ const minOffset = Math.max(0, anchorIndex - keepBehind)
131
+ const drop = Math.min(Math.max(0, minOffset - base), Math.max(0, items.length - 1))
132
+ if (drop === 0) return { base, items }
133
+
134
+ const nextBase = base + drop
135
+ debug.log("results:evict", { fromBase: base, toBase: nextBase, dropped: drop, kept: items.length - drop, anchorIndex })
136
+ return { base: nextBase, items: items.slice(drop) }
137
+ }
138
+ const resultRenderWindow = createMemo(() => {
139
+ const base = resultBaseOffset()
140
+ const cached = results()
141
+ const visible = visibleResultRows()
142
+ const overscan = visible * RESULT_OVERSCAN_MULTIPLIER
143
+ const index = selected()
144
+ const cachedStart = Math.max(0, index - overscan - base)
145
+ const cachedEnd = Math.min(cached.length, index + visible + overscan - base)
146
+ return { start: base + cachedStart, end: base + cachedEnd, items: cached.slice(cachedStart, cachedEnd) }
147
+ })
148
+
149
+ let lastResultRenderWindow = ""
150
+ createEffect(() => {
151
+ const window = resultRenderWindow()
152
+ const key = `${window.start}:${window.end}:${resultBaseOffset()}:${nextResultOffset()}:${results().length}`
153
+ if (key === lastResultRenderWindow) return
154
+ lastResultRenderWindow = key
155
+ debug.log("results:render-window", {
156
+ selected: selected(),
157
+ baseOffset: resultBaseOffset(),
158
+ nextOffset: nextResultOffset(),
159
+ totalLoaded: results().length,
160
+ start: window.start,
161
+ end: window.end,
162
+ rendered: window.items.length,
163
+ visibleRows: visibleResultRows(),
164
+ overscanRows: visibleResultRows() * RESULT_OVERSCAN_MULTIPLIER,
165
+ pageInfo: resultPageInfo(),
166
+ })
167
+ })
52
168
 
53
169
  createEffect(() => {
54
170
  const q = query().trim()
171
+ const role = ownerRole()
55
172
  setError("")
56
173
  setHasMore(true)
174
+ if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
175
+ if (resultPreviousTimer) clearTimeout(resultPreviousTimer)
176
+ resultPrefetchTimer = undefined
177
+ resultPreviousTimer = undefined
178
+ advanceSelectionAfterLoad = false
179
+ advanceSelectionBeforeLoad = false
180
+ setLoadingMore(false)
181
+ setLoadingPreviousResults(false)
182
+ setPrefetchingResults(false)
57
183
  const db = dbPath()
58
184
  const dir = directory
59
185
 
60
186
  if (!q) {
61
187
  setLoading(true)
188
+ const limit = recentBatchSize()
62
189
  const timer = setTimeout(() => {
63
190
  debug.time("query:recent")
64
191
  try {
65
- const batch = recentSessionMessages({ limit: RECENT_BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
66
- setResults(batch)
67
- setHasMore(batch.length >= RECENT_BATCH_SIZE)
68
- setSelected(0)
192
+ const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
193
+ solidBatch(() => {
194
+ setResults(batch)
195
+ setResultBaseOffset(0)
196
+ setNextResultOffset(batch.length)
197
+ setHasMore(batch.length >= limit)
198
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
199
+ setSelected(0)
200
+ })
69
201
  } catch (err) {
70
- setResults([])
202
+ solidBatch(() => {
203
+ setResults([])
204
+ setResultBaseOffset(0)
205
+ setNextResultOffset(0)
206
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
207
+ })
71
208
  setError(err instanceof Error ? err.message : String(err))
72
209
  }
73
210
  debug.timeEnd("query:recent")
@@ -79,15 +216,26 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
79
216
 
80
217
  setLoading(true)
81
218
  setBusy(true)
219
+ const limit = searchBatchSize()
82
220
  const timer = setTimeout(() => {
83
221
  debug.time("query:search")
84
222
  try {
85
- const batch = searchSessionMessages(q, { limit: BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
86
- setResults(batch)
87
- setHasMore(batch.length >= BATCH_SIZE)
88
- setSelected(0)
223
+ const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
224
+ solidBatch(() => {
225
+ setResults(batch)
226
+ setResultBaseOffset(0)
227
+ setNextResultOffset(batch.length)
228
+ setHasMore(batch.length >= limit)
229
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
230
+ setSelected(0)
231
+ })
89
232
  } catch (err) {
90
- setResults([])
233
+ solidBatch(() => {
234
+ setResults([])
235
+ setResultBaseOffset(0)
236
+ setNextResultOffset(0)
237
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
238
+ })
91
239
  setError(err instanceof Error ? err.message : String(err))
92
240
  } finally {
93
241
  debug.timeEnd("query:search")
@@ -98,14 +246,145 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
98
246
  onCleanup(() => clearTimeout(timer))
99
247
  })
100
248
 
249
+ const loadMoreResults = (advance = false) => {
250
+ if (advance) advanceSelectionAfterLoad = true
251
+ if (!hasMore()) {
252
+ advanceSelectionAfterLoad = false
253
+ return
254
+ }
255
+ if (loadingMore() || loadingPreviousResults() || prefetchingResults() || busy() || loading()) return
256
+
257
+ const offset = nextResultOffset()
258
+ const q = query().trim()
259
+ const role = ownerRole()
260
+ const db = dbPath()
261
+ const dir = directory
262
+ const limit = q ? searchBatchSize() : recentBatchSize()
263
+
264
+ advance ? setLoadingMore(true) : setPrefetchingResults(true)
265
+ debug.time("query:load-more")
266
+ try {
267
+ const batch = q
268
+ ? searchSessionMessages(q, { limit, offset, dbPath: db, directory: dir, role })
269
+ : recentSessionMessages({ limit, offset, dbPath: db, directory: dir, role })
270
+ const nextHasMore = batch.length >= limit
271
+ const nextLoadedUntil = offset + batch.length
272
+ const previousSelected = selected()
273
+ const nextSelected = advanceSelectionAfterLoad && batch.length > 0 ? offset : selected()
274
+ const nextCache = trimResultCache([...results(), ...batch], nextSelected)
275
+ debug.log("results:prefetch", {
276
+ offset,
277
+ limit,
278
+ added: batch.length,
279
+ baseOffset: nextCache.base,
280
+ cached: nextCache.items.length,
281
+ totalLoaded: nextLoadedUntil,
282
+ hasMore: nextHasMore,
283
+ advance,
284
+ })
285
+ const shouldAdvance = advanceSelectionAfterLoad
286
+ solidBatch(() => {
287
+ setResultBaseOffset(nextCache.base)
288
+ setNextResultOffset(nextLoadedUntil)
289
+ setResults(nextCache.items)
290
+ setResultPageInfo({ loadedUntil: nextLoadedUntil, hasMore: nextHasMore, pageSize: limit, lastOffset: offset, lastAdded: batch.length })
291
+ if (!nextHasMore) setHasMore(false)
292
+ if (shouldAdvance && batch.length > 0) setSelected(offset)
293
+ })
294
+ if (shouldAdvance) {
295
+ debug.log("results:advance-after-load", { from: previousSelected, offset, added: batch.length })
296
+ advanceSelectionAfterLoad = false
297
+ }
298
+ } catch (err) {
299
+ debug.log("results:load-more:error", err instanceof Error ? err.message : String(err))
300
+ advanceSelectionAfterLoad = false
301
+ } finally {
302
+ debug.timeEnd("query:load-more")
303
+ advance ? setLoadingMore(false) : setPrefetchingResults(false)
304
+ }
305
+ }
306
+
307
+ const loadPreviousResults = (advance = false) => {
308
+ if (advance) advanceSelectionBeforeLoad = true
309
+ const base = resultBaseOffset()
310
+ if (base <= 0) {
311
+ advanceSelectionBeforeLoad = false
312
+ return
313
+ }
314
+ if (loadingMore() || loadingPreviousResults() || prefetchingResults() || busy() || loading()) return
315
+
316
+ const q = query().trim()
317
+ const role = ownerRole()
318
+ const db = dbPath()
319
+ const dir = directory
320
+ const pageSize = q ? searchBatchSize() : recentBatchSize()
321
+ const offset = Math.max(0, base - pageSize)
322
+ const limit = base - offset
323
+
324
+ setLoadingPreviousResults(true)
325
+ debug.time("query:load-before")
326
+ try {
327
+ const batch = q
328
+ ? searchSessionMessages(q, { limit, offset, dbPath: db, directory: dir, role })
329
+ : recentSessionMessages({ limit, offset, dbPath: db, directory: dir, role })
330
+ const nextSelected = advanceSelectionBeforeLoad && batch.length > 0 ? base - 1 : selected()
331
+ debug.log("results:load-before", { offset, limit, added: batch.length, fromBase: base, toBase: offset, cached: results().length + batch.length, advance })
332
+ const shouldAdvance = advanceSelectionBeforeLoad
333
+ solidBatch(() => {
334
+ setResultBaseOffset(offset)
335
+ setResults([...batch, ...results()])
336
+ setResultPageInfo({ loadedUntil: nextResultOffset(), hasMore: hasMore(), pageSize, lastOffset: offset, lastAdded: batch.length })
337
+ if (shouldAdvance && batch.length > 0) setSelected(nextSelected)
338
+ })
339
+ if (shouldAdvance) advanceSelectionBeforeLoad = false
340
+ } catch (err) {
341
+ debug.log("results:load-before:error", err instanceof Error ? err.message : String(err))
342
+ advanceSelectionBeforeLoad = false
343
+ } finally {
344
+ debug.timeEnd("query:load-before")
345
+ setLoadingPreviousResults(false)
346
+ }
347
+ }
348
+
349
+ const scheduleResultPrefetch = (advance = false) => {
350
+ if (advance) advanceSelectionAfterLoad = true
351
+ if (resultPrefetchTimer || loadingMore() || loadingPreviousResults() || prefetchingResults()) return
352
+ debug.log("results:prefetch-scheduled", { advance, pendingAdvance: advanceSelectionAfterLoad, pageInfo: resultPageInfo() })
353
+ resultPrefetchTimer = setTimeout(() => {
354
+ resultPrefetchTimer = undefined
355
+ loadMoreResults(advanceSelectionAfterLoad)
356
+ }, 1)
357
+ }
358
+
359
+ const schedulePreviousResultsLoad = (advance = false) => {
360
+ if (advance) advanceSelectionBeforeLoad = true
361
+ if (resultPreviousTimer || loadingMore() || loadingPreviousResults() || prefetchingResults()) return
362
+ debug.log("results:load-before-scheduled", { advance, pendingAdvance: advanceSelectionBeforeLoad, baseOffset: resultBaseOffset(), pageInfo: resultPageInfo() })
363
+ resultPreviousTimer = setTimeout(() => {
364
+ resultPreviousTimer = undefined
365
+ loadPreviousResults(advanceSelectionBeforeLoad)
366
+ }, 1)
367
+ }
368
+
369
+ let lastResultPrefetchDecision = ""
370
+
101
371
  const move = (delta: number) => {
102
372
  if (results().length === 0) return
103
373
  setSelected((index) => {
374
+ const base = resultBaseOffset()
375
+ const cachedEnd = base + results().length
104
376
  const next = index + delta
105
377
  let finalIndex = next
106
- if (next < 0) finalIndex = results().length - 1
107
- else if (next >= results().length) finalIndex = results().length - 1
108
-
378
+ if (next < 0) finalIndex = cachedEnd - 1
379
+ else if (next < base) {
380
+ schedulePreviousResultsLoad(true)
381
+ finalIndex = base
382
+ }
383
+ else if (next >= cachedEnd) {
384
+ if (hasMore()) scheduleResultPrefetch(true)
385
+ finalIndex = cachedEnd - 1
386
+ }
387
+
109
388
  if (finalIndex !== index) debug.time("nav:total")
110
389
  return finalIndex
111
390
  })
@@ -113,7 +392,8 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
113
392
 
114
393
  createEffect(() => {
115
394
  const index = selected()
116
- const row = resultScroll?.getChildren()[index]
395
+ const window = resultRenderWindow()
396
+ const row = resultScroll?.getChildren()[index - window.start]
117
397
  if (!resultScroll || !row) return
118
398
  const y = row.y - resultScroll.y
119
399
  if (y < 0) {
@@ -126,52 +406,160 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
126
406
  })
127
407
 
128
408
  createEffect(() => {
129
- const index = selected()
130
- const total = results().length
131
- if (index < total - 3) return
132
- if (!hasMore() || loadingMore() || busy() || loading()) return
409
+ const state = resultPrefetchState()
410
+ const blockedBy = !hasMore()
411
+ ? "no-more"
412
+ : loadingMore()
413
+ ? "loading-more"
414
+ : loadingPreviousResults()
415
+ ? "loading-before"
416
+ : prefetchingResults()
417
+ ? "prefetching"
418
+ : busy()
419
+ ? "busy"
420
+ : loading()
421
+ ? "loading"
422
+ : ""
423
+ const decisionKey = `${selected()}:${state.cachedEnd}:${state.rowsAhead}:${state.threshold}:${blockedBy}:${state.shouldPrefetch}`
424
+ if (decisionKey !== lastResultPrefetchDecision) {
425
+ lastResultPrefetchDecision = decisionKey
426
+ debug.log("results:prefetch-decision", { selected: selected(), ...state, blockedBy: blockedBy || undefined })
427
+ }
428
+ if (!state.shouldPrefetch || blockedBy) return
133
429
 
134
- const q = query().trim()
135
- const db = dbPath()
136
- const dir = directory
430
+ const timer = setTimeout(() => scheduleResultPrefetch(false), 100)
431
+ onCleanup(() => clearTimeout(timer))
432
+ })
137
433
 
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)
434
+ const previewContentHeight = () => {
435
+ const children = previewScroll?.getChildren()
436
+ const lastChild = children?.[children.length - 1] as { y: number; height: number } | undefined
437
+ return lastChild ? lastChild.y + lastChild.height : 0
438
+ }
439
+
440
+ const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
441
+ const item = selectedResult()
442
+ const first = previewParts()[0]
443
+ if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) return
444
+ visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
445
+ debug.time("preview:load-before")
446
+ try {
447
+ const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
448
+ debug.log("preview:load-before", {
449
+ item: item.id,
450
+ added: page.parts.length,
451
+ hasMoreBefore: page.hasMoreBefore,
452
+ preserveScroll,
453
+ visibleLoad,
454
+ first: page.parts[0]?.id,
455
+ last: page.parts.at(-1)?.id,
456
+ })
457
+ if (page.parts.length > 0) {
458
+ setPreviewParts((prev) => [...page.parts, ...prev])
459
+ if (preserveScroll) {
460
+ setTimeout(() => {
461
+ const delta = previewContentHeight() - previousContentHeight
462
+ if (delta > 0) previewScroll?.scrollBy(delta)
463
+ }, 1)
464
+ }
152
465
  }
153
- }, 100)
154
- onCleanup(() => { clearTimeout(timer); setLoadingMore(false) })
155
- })
466
+ setHasMorePreviewBefore(page.hasMoreBefore)
467
+ } catch (err) {
468
+ debug.log("preview:load-before:error", err instanceof Error ? err.message : String(err))
469
+ } finally {
470
+ debug.timeEnd("preview:load-before")
471
+ visibleLoad ? setLoadingPreviewMore(false) : setPrefetchingPreviewBefore(false)
472
+ }
473
+ }
474
+
475
+ const loadPreviewAfter = (visibleLoad = false) => {
476
+ const item = selectedResult()
477
+ const last = previewParts().at(-1)
478
+ if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) return
479
+ visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewAfter(true)
480
+ debug.time("preview:load-after")
481
+ try {
482
+ const page = loadConversationAfter(item, { id: last.id, timeCreated: last.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
483
+ debug.log("preview:load-after", {
484
+ item: item.id,
485
+ added: page.parts.length,
486
+ hasMoreAfter: page.hasMoreAfter,
487
+ visibleLoad,
488
+ first: page.parts[0]?.id,
489
+ last: page.parts.at(-1)?.id,
490
+ })
491
+ if (page.parts.length > 0) setPreviewParts((prev) => [...prev, ...page.parts])
492
+ setHasMorePreviewAfter(page.hasMoreAfter)
493
+ } catch (err) {
494
+ debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
495
+ } finally {
496
+ debug.timeEnd("preview:load-after")
497
+ visibleLoad ? setLoadingPreviewMore(false) : setPrefetchingPreviewAfter(false)
498
+ }
499
+ }
500
+
501
+ const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
502
+ if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) return
503
+ if (previewBeforeTimer) {
504
+ if (pendingPreviewBefore) {
505
+ pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
506
+ pendingPreviewBefore.visibleLoad = pendingPreviewBefore.visibleLoad || visibleLoad
507
+ }
508
+ return
509
+ }
510
+ pendingPreviewBefore = { previousContentHeight, preserveScroll, visibleLoad }
511
+ debug.log("preview:prefetch-before-scheduled", { preserveScroll, visibleLoad })
512
+ previewBeforeTimer = setTimeout(() => {
513
+ const pending = pendingPreviewBefore
514
+ previewBeforeTimer = undefined
515
+ pendingPreviewBefore = undefined
516
+ if (pending) loadPreviewBefore(pending.previousContentHeight, pending.preserveScroll, pending.visibleLoad)
517
+ }, 1)
518
+ }
519
+
520
+ const schedulePreviewAfter = (visibleLoad = false) => {
521
+ if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) return
522
+ pendingPreviewAfterVisible = pendingPreviewAfterVisible || visibleLoad
523
+ if (previewAfterTimer) return
524
+ debug.log("preview:prefetch-after-scheduled", { visibleLoad })
525
+ previewAfterTimer = setTimeout(() => {
526
+ const pendingVisibleLoad = pendingPreviewAfterVisible
527
+ previewAfterTimer = undefined
528
+ pendingPreviewAfterVisible = false
529
+ loadPreviewAfter(pendingVisibleLoad)
530
+ }, 1)
531
+ }
156
532
 
157
533
  let lastPreviewItemId = ""
158
534
  createEffect(() => {
159
535
  const item = selectedResult()
160
- const range = previewRange()
161
536
  if (!item) {
537
+ cancelPreviewPrefetch()
162
538
  setPreviewParts([])
539
+ setHasMorePreviewBefore(false)
540
+ setHasMorePreviewAfter(false)
163
541
  return
164
542
  }
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
- }
543
+ if (item.id === lastPreviewItemId) return
544
+ lastPreviewItemId = item.id
545
+ cancelPreviewPrefetch()
546
+ debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
171
547
  const db = dbPath()
172
548
  debug.time("preview:load")
173
549
  try {
174
- setPreviewParts(loadConversationWindow(item, { before: range.before, after: range.after, dbPath: db }))
550
+ const page = loadConversationAround(item, { before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER, dbPath: db })
551
+ debug.log("preview:init", {
552
+ item: item.id,
553
+ session: item.sessionID,
554
+ parts: page.parts.length,
555
+ hasMoreBefore: page.hasMoreBefore,
556
+ hasMoreAfter: page.hasMoreAfter,
557
+ first: page.parts[0]?.id,
558
+ last: page.parts.at(-1)?.id,
559
+ })
560
+ setPreviewParts(page.parts)
561
+ setHasMorePreviewBefore(page.hasMoreBefore)
562
+ setHasMorePreviewAfter(page.hasMoreAfter)
175
563
  } catch {}
176
564
  debug.timeEnd("nav:total")
177
565
  debug.timeEnd("preview:load")
@@ -181,16 +569,36 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
181
569
  const item = selectedResult()
182
570
  if (!item) return
183
571
  const interval = setInterval(() => {
572
+ if (loadingPreviewMore() || prefetchingPreviewBefore() || prefetchingPreviewAfter()) return
184
573
  const scroll = previewScroll
185
574
  const children = scroll?.getChildren()
186
575
  if (!scroll || !children || children.length === 0) return
187
- const range = previewRange()
188
576
  const lastChild = children[children.length - 1] as { y: number; height: number }
189
577
  const totalContentHeight = lastChild.y + lastChild.height
190
- const atTop = scroll.y <= 0 && range.before > 0
578
+ const atTop = scroll.y <= 0
579
+ const prefetchDistance = Math.max(2, Math.floor(scroll.height * PREVIEW_PREFETCH_VIEWPORTS))
580
+ const nearTop = scroll.y <= prefetchDistance
191
581
  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 }))
582
+ const nearBottom = scroll.y + scroll.height >= totalContentHeight - prefetchDistance
583
+ if (nearTop || nearBottom) {
584
+ debug.log("preview:scroll-edge", {
585
+ y: scroll.y,
586
+ height: scroll.height,
587
+ contentHeight: totalContentHeight,
588
+ prefetchDistance,
589
+ atTop,
590
+ nearTop,
591
+ atBottom,
592
+ nearBottom,
593
+ hasMoreBefore: hasMorePreviewBefore(),
594
+ hasMoreAfter: hasMorePreviewAfter(),
595
+ prefetchingBefore: prefetchingPreviewBefore(),
596
+ prefetchingAfter: prefetchingPreviewAfter(),
597
+ children: children.length,
598
+ })
599
+ }
600
+ if (atTop && hasMorePreviewBefore()) schedulePreviewBefore(totalContentHeight, false, true)
601
+ if (nearBottom && hasMorePreviewAfter()) schedulePreviewAfter(atBottom)
194
602
  }, 400)
195
603
  onCleanup(() => clearInterval(interval))
196
604
  })
@@ -229,50 +637,54 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
229
637
  el?.blur?.()
230
638
  }
231
639
 
640
+ const toggleOwnerFilter = () => {
641
+ setOwnerFilter((filter) => filter === "all" ? "user" : filter === "user" ? "assistant" : "all")
642
+ }
643
+
232
644
  useKeyboard((evt) => {
233
645
  if (!props.api.ui.dialog.open) return
234
646
 
235
- if (isKey(evt, "down") || isKey(evt, "up")) {
647
+ if (mode() !== "normal") return
648
+
649
+ if (matchesKey(evt, props.config.keys.moveDown) || matchesKey(evt, props.config.keys.moveUp)) {
236
650
  prevent(evt)
237
- isKey(evt, "down") ? move(1) : move(-1)
651
+ matchesKey(evt, props.config.keys.moveDown) ? move(1) : move(-1)
238
652
  return
239
653
  }
240
654
 
241
- if (mode() === "normal") {
242
- if (isKey(evt, "j")) {
243
- prevent(evt)
244
- move(1)
245
- return
246
- }
247
- if (isKey(evt, "k")) {
248
- prevent(evt)
249
- move(-1)
250
- return
251
- }
252
- if (isKey(evt, "q")) {
253
- prevent(evt)
254
- props.onClose()
255
- return
256
- }
257
- if (isKey(evt, "d")) {
258
- scrollPreview(1, evt)
259
- return
260
- }
261
- if (isKey(evt, "u")) {
262
- scrollPreview(-1, evt)
263
- return
264
- }
265
- if (isKey(evt, "/")) {
266
- prevent(evt)
267
- setMode("insert")
268
- focusInput()
269
- return
270
- }
271
- if (isKey(evt, "enter", "return")) {
272
- prevent(evt)
273
- open()
274
- return
275
- }
655
+ if (matchesKey(evt, props.config.keys.open)) {
656
+ prevent(evt)
657
+ open()
658
+ return
659
+ }
660
+
661
+ if (matchesKey(evt, props.config.keys.close)) {
662
+ prevent(evt)
663
+ props.onClose()
664
+ return
665
+ }
666
+
667
+ if (matchesKey(evt, props.config.keys.scrollPreviewDown)) {
668
+ scrollPreview(1, evt)
669
+ return
670
+ }
671
+
672
+ if (matchesKey(evt, props.config.keys.scrollPreviewUp)) {
673
+ scrollPreview(-1, evt)
674
+ return
675
+ }
676
+
677
+ if (matchesKey(evt, props.config.keys.toggleOwner)) {
678
+ prevent(evt)
679
+ toggleOwnerFilter()
680
+ return
681
+ }
682
+
683
+ if (matchesKey(evt, props.config.keys.insertMode)) {
684
+ prevent(evt)
685
+ setMode("insert")
686
+ focusInput()
687
+ return
276
688
  }
277
689
  })
278
690
 
@@ -303,12 +715,17 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
303
715
  focusedBackgroundColor={theme().backgroundPanel}
304
716
  onInput={(value) => setQuery(value)}
305
717
  onKeyDown={(evt: ParsedKey) => {
306
- if (isKey(evt, "down") || isKey(evt, "up")) {
718
+ if (matchesKey(evt, inputKeys().moveDown) || matchesKey(evt, inputKeys().moveUp)) {
307
719
  prevent(evt)
308
- isKey(evt, "down") ? move(1) : move(-1)
720
+ matchesKey(evt, inputKeys().moveDown) ? move(1) : move(-1)
309
721
  return
310
722
  }
311
- if (evt.ctrl && isKey(evt, "q")) {
723
+ if (matchesKey(evt, inputKeys().open)) {
724
+ prevent(evt)
725
+ open()
726
+ return
727
+ }
728
+ if (matchesKey(evt, inputKeys().normalMode)) {
312
729
  prevent(evt)
313
730
  setMode("normal")
314
731
  blurInput()
@@ -316,13 +733,13 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
316
733
  }}
317
734
  flexGrow={1}
318
735
  />
319
- <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>
736
+ <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>
320
737
  </box>
321
738
  </box>
322
739
 
323
740
  <box flexDirection="row" flexGrow={1} minHeight={0}>
324
741
  <box width={leftWidth()} flexDirection="column" minHeight={0} backgroundColor={theme().backgroundPanel}>
325
- <scrollbox ref={(element: ScrollBoxRenderable) => (resultScroll = element)} flexGrow={1} minHeight={0} verticalScrollbarOptions={{ visible: false }}>
742
+ <scrollbox ref={(element: ScrollBoxRenderable) => (resultScroll = element)} flexGrow={1} minHeight={0} verticalScrollbarOptions={{ visible: true }}>
326
743
  <Show
327
744
  when={!error()}
328
745
  fallback={
@@ -333,19 +750,22 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
333
750
  }
334
751
  >
335
752
  <Show when={!loading()}>
336
- <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
- )}
753
+ <Show when={results().length > 0} fallback={<EmptyState query={query()} owner={ownerLabel()} theme={theme()} />}>
754
+ <For each={resultRenderWindow().items}>
755
+ {(item, index) => {
756
+ const absoluteIndex = () => resultRenderWindow().start + index()
757
+ return (
758
+ <ResultRow
759
+ item={item}
760
+ active={absoluteIndex() === selected()}
761
+ width={leftWidth()}
762
+ query={query()}
763
+ theme={theme()}
764
+ onMouseOver={() => setSelected(absoluteIndex())}
765
+ onOpen={open}
766
+ />
767
+ )
768
+ }}
349
769
  </For>
350
770
  <Show when={loadingMore()}>
351
771
  <SkeletonRow theme={theme()} />
@@ -382,23 +802,21 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
382
802
  <Show when={mode() === "normal"}>
383
803
  <box paddingLeft={4} paddingRight={4} flexDirection="row" backgroundColor={theme().backgroundElement} gap={2}>
384
804
  <text fg={theme().accent}><span style={{ bold: true }}>NORMAL</span></text>
385
- <text fg={theme().textMuted}>·</text>
386
- <text fg={theme().text}>j/k move</text>
387
- <text fg={theme().textMuted}>·</text>
388
- <text fg={theme().textMuted}>d/u scroll</text>
389
- <text fg={theme().textMuted}>·</text>
390
- <text fg={theme().text}>/ search</text>
391
- <text fg={theme().textMuted}>·</text>
392
- <text fg={theme().textMuted}>enter open</text>
393
- <text fg={theme().textMuted}>·</text>
394
- <text fg={theme().text}>q close</text>
805
+ <For each={normalHelpItems()}>
806
+ {(item, index) => (
807
+ <>
808
+ <text fg={theme().textMuted}>·</text>
809
+ <text fg={index() % 2 === 0 ? theme().text : theme().textMuted}>{item}</text>
810
+ </>
811
+ )}
812
+ </For>
395
813
  </box>
396
814
  </Show>
397
815
  <Show when={mode() === "insert"}>
398
816
  <box paddingLeft={4} paddingRight={4} flexDirection="row" backgroundColor={theme().backgroundElement} gap={2}>
399
817
  <text fg={theme().warning}><span style={{ bold: true }}>INSERT</span></text>
400
818
  <text fg={theme().textMuted}>·</text>
401
- <text fg={theme().textMuted}>↑/↓ move · ^Q normal</text>
819
+ <text fg={theme().textMuted}>{keyListLabel(inputKeys().moveUp)}/{keyListLabel(inputKeys().moveDown)} move · {keyListLabel(inputKeys().normalMode)} normal</text>
402
820
  </box>
403
821
  </Show>
404
822