@bojackduy/opencode-telescope 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -1,8 +1,22 @@
1
1
  # opencode-telescope
2
2
 
3
- Telescope-style session conversation search for the OpenCode TUI.
3
+ Fuzzy search across all your OpenCode conversations — grep through session and chat history, find code snippets, and jump to any chat instantly.
4
4
 
5
- This plugin searches OpenCode's local SQLite session database directly in read-only mode, then opens the selected session with the existing TUI route.
5
+ > Inspired by [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) a fuzzy finder for your conversation history.
6
+
7
+ ## Use cases
8
+
9
+ - **"I know I discussed this somewhere"** — grep all your sessions by keyword
10
+ - **"Find that code snippet"** — search for code you saw in a past LLM response
11
+ - **"Revisit a decision"** — find the conversation where you chose approach X
12
+ - **"Session journal"** — browse your entire conversation history like a timeline
13
+
14
+ ## Features
15
+
16
+ - **Fuzzy grep** — search across all session messages by text
17
+ - **Live preview** — preview the matched conversation result before opening
18
+ - **Find & jump** — select any result and jump straight to that session
19
+ - **Neovim Telescope-style UX** — familiar `<leader>f` keybind and `/telescope` command
6
20
 
7
21
  ## Installation
8
22
 
@@ -22,4 +36,14 @@ Add the plugin to your `tui.json`:
22
36
 
23
37
  ## Usage
24
38
 
25
- Run `/telescope` or shortcut `<lead>+f` in OpenCode to search and jump to any session.
39
+ | Action | Key / Command |
40
+ |--------|--------------|
41
+ | Open search | `<leader>f` or `/telescope` |
42
+ | Type to filter | Fuzzy match against conversation text |
43
+ | Navigate results | `↑` / `↓` or `Ctrl+j` / `Ctrl+k` |
44
+ | Preview | Select a result to see the conversation preview |
45
+ | Open | Press `Enter` to jump to the selected session |
46
+
47
+ ## How it works
48
+
49
+ Reads the OpenCode local SQLite session database in read-only mode, parses conversations into searchable text, and opens the selected session through the existing TUI route.
@@ -228,10 +228,20 @@ function searchResultPreviewPart(item: SearchResult): ConversationPreviewPart {
228
228
  }
229
229
 
230
230
  function conversationMatch(part: ConversationPreviewPart, item: SearchResult) {
231
- if (!part.target) return
232
- const index = part.text.toLowerCase().indexOf(item.match.toLowerCase())
233
- if (index === -1 || !item.match) return
234
- return { start: index, end: index + item.match.length }
231
+ if (!part.target || !item.match) return
232
+ const tokens = item.match.split(/\s+/)
233
+ const lowerText = part.text.toLowerCase()
234
+ let searchPos = 0
235
+ let firstStart = -1
236
+ let lastEnd = -1
237
+ for (const token of tokens) {
238
+ const index = lowerText.indexOf(token.toLowerCase(), searchPos)
239
+ if (index === -1) return
240
+ if (firstStart === -1) firstStart = index
241
+ searchPos = index + token.length
242
+ lastEnd = searchPos
243
+ }
244
+ return { start: firstStart, end: lastEnd }
235
245
  }
236
246
 
237
247
  function conversationMarkdown(part: ConversationPreviewPart, item: SearchResult) {
@@ -243,14 +253,23 @@ function conversationMarkdown(part: ConversationPreviewPart, item: SearchResult)
243
253
  function matchExcerpt(text: string, query: string, radius = 80) {
244
254
  const needle = query.trim()
245
255
  if (!needle) return
246
- const start = text.toLowerCase().indexOf(needle.toLowerCase())
247
- if (start === -1) return
248
- const end = start + needle.length
249
- const beforeStart = Math.max(0, start - radius)
250
- const afterEnd = Math.min(text.length, end + radius)
256
+ const tokens = needle.split(/\s+/)
257
+ const lowerText = text.toLowerCase()
258
+ let searchPos = 0
259
+ let firstStart = -1
260
+ let lastEnd = -1
261
+ for (const token of tokens) {
262
+ const start = lowerText.indexOf(token.toLowerCase(), searchPos)
263
+ if (start === -1) return
264
+ if (firstStart === -1) firstStart = start
265
+ searchPos = start + token.length
266
+ lastEnd = searchPos
267
+ }
268
+ const beforeStart = Math.max(0, firstStart - radius)
269
+ const afterEnd = Math.min(text.length, lastEnd + radius)
251
270
  return {
252
- before: `${beforeStart > 0 ? "..." : ""}${text.slice(beforeStart, start).replace(/\s+/g, " ")}`,
253
- match: text.slice(start, end),
254
- after: `${text.slice(end, afterEnd).replace(/\s+/g, " ")}${afterEnd < text.length ? "..." : ""}`,
271
+ before: `${beforeStart > 0 ? "..." : ""}${text.slice(beforeStart, firstStart).replace(/\s+/g, " ")}`,
272
+ match: text.slice(firstStart, lastEnd),
273
+ after: `${text.slice(lastEnd, afterEnd).replace(/\s+/g, " ")}${afterEnd < text.length ? "..." : ""}`,
255
274
  }
256
275
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-telescope",
4
- "version": "0.1.3",
5
- "description": "Telescope-style session conversation search for OpenCode TUI",
4
+ "version": "0.1.4",
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",
8
8
  "repository": {
@@ -18,7 +18,16 @@
18
18
  "opencode-plugin",
19
19
  "tui",
20
20
  "telescope",
21
- "search"
21
+ "search",
22
+ "session",
23
+ "conversation",
24
+ "history",
25
+ "fuzzy",
26
+ "fuzzy-finder",
27
+ "grep",
28
+ "finder",
29
+ "neovim",
30
+ "chat-history"
22
31
  ],
23
32
  "scripts": {
24
33
  "typecheck": "bun tsc -p tsconfig.json --noEmit",
package/search.ts CHANGED
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite"
2
2
  import { existsSync, readdirSync } from "node:fs"
3
3
  import { homedir } from "node:os"
4
4
  import path from "node:path"
5
+ import { debug } from "./ui/debug.ts"
5
6
 
6
7
  export type SearchResult = {
7
8
  id: string
@@ -100,22 +101,22 @@ export function resolveDatabasePath() {
100
101
  }
101
102
  }
102
103
 
103
- export function searchSessionMessages(query: string, options?: { limit?: number; dbPath?: string; directory?: string }) {
104
+ export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
104
105
  const term = query.trim()
105
106
  if (!term) return []
106
107
  if (options?.dbPath === ":memory:") return []
107
108
  const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
108
109
  try {
109
- return searchRows(db, term, options?.limit ?? 80, options?.directory)
110
+ return searchRows(db, term, options?.limit ?? 80, options?.directory, options?.offset)
110
111
  } finally {
111
112
  db.close()
112
113
  }
113
114
  }
114
115
 
115
- export function recentSessionMessages(options?: { limit?: number; dbPath?: string; directory?: string }) {
116
+ export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
116
117
  const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
117
118
  try {
118
- return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory).flatMap(
119
+ return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset).flatMap(
119
120
  (row) => rowToSearchResult(row, "") ?? [],
120
121
  )
121
122
  } finally {
@@ -124,34 +125,67 @@ export function recentSessionMessages(options?: { limit?: number; dbPath?: strin
124
125
  }
125
126
 
126
127
  export function loadConversationWindow(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }) {
128
+ debug.time("preview:query:total")
127
129
  const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
128
130
  try {
129
- return db
130
- .query<ConversationRow, [string, string, number, number]>(`
131
- WITH visible AS (
132
- SELECT p.id,
133
- p.message_id,
134
- p.session_id,
135
- json_extract(m.data, '$.role') AS role,
136
- json_extract(p.data, '$.type') AS type,
137
- p.time_created,
138
- p.data,
139
- row_number() OVER (ORDER BY p.time_created ASC, p.id ASC) AS rn
140
- FROM part p
141
- JOIN message m ON m.id = p.message_id
142
- WHERE p.session_id = ?
143
- AND json_extract(p.data, '$.type') IN ('text', 'reasoning', 'tool')
144
- AND json_extract(m.data, '$.role') IN ('user', 'assistant')
145
- ), hit AS (
146
- SELECT rn FROM visible WHERE id = ?
147
- )
148
- SELECT id, message_id, session_id, role, type, time_created, data
149
- FROM visible
150
- WHERE rn BETWEEN (SELECT rn FROM hit) - ? AND (SELECT rn FROM hit) + ?
151
- ORDER BY time_created ASC, id ASC
152
- `)
153
- .all(result.sessionID, result.id, options?.before ?? 12, options?.after ?? 24)
154
- .flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
131
+ const before = options?.before ?? 3
132
+ const after = options?.after ?? 6
133
+
134
+ debug.time("preview:query:hit")
135
+ const hit = db.query<{ time_created: number }, [string]>(
136
+ "SELECT time_created FROM part WHERE id = ?",
137
+ ).get(result.id)
138
+ debug.timeEnd("preview:query:hit")
139
+ if (!hit) {
140
+ debug.timeEnd("preview:query:total")
141
+ return []
142
+ }
143
+
144
+ const fetchBefore = Math.max(before * 4, 30)
145
+ const fetchAfter = Math.max(after * 4, 50)
146
+
147
+ debug.time("preview:query:before")
148
+ const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
149
+ SELECT p.id, p.message_id, p.session_id,
150
+ json_extract(m.data, '$.role') AS role,
151
+ json_extract(p.data, '$.type') AS type,
152
+ p.time_created, p.data
153
+ FROM part p
154
+ JOIN message m ON m.id = p.message_id
155
+ WHERE p.session_id = ?
156
+ AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
157
+ ORDER BY p.time_created DESC, p.id DESC
158
+ LIMIT ?
159
+ `).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchBefore)
160
+ debug.timeEnd("preview:query:before")
161
+
162
+ debug.time("preview:query:after")
163
+ const afterRows = db.query<ConversationRow, [string, number, number, string, number]>(`
164
+ SELECT p.id, p.message_id, p.session_id,
165
+ json_extract(m.data, '$.role') AS role,
166
+ json_extract(p.data, '$.type') AS type,
167
+ p.time_created, p.data
168
+ FROM part p
169
+ JOIN message m ON m.id = p.message_id
170
+ WHERE p.session_id = ?
171
+ AND (p.time_created > ? OR (p.time_created = ? AND p.id >= ?))
172
+ ORDER BY p.time_created ASC, p.id ASC
173
+ LIMIT ?
174
+ `).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchAfter)
175
+ debug.timeEnd("preview:query:after")
176
+
177
+ debug.time("preview:query:parse")
178
+ const isValid = (row: ConversationRow) =>
179
+ (row.role === "user" || row.role === "assistant") &&
180
+ (row.type === "text" || row.type === "reasoning" || row.type === "tool")
181
+
182
+ const parts = [
183
+ ...beforeRows.filter(isValid).slice(0, before).reverse(),
184
+ ...afterRows.filter(isValid).slice(0, after + 1),
185
+ ].flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
186
+ debug.timeEnd("preview:query:parse")
187
+ debug.timeEnd("preview:query:total")
188
+ return parts
155
189
  } finally {
156
190
  db.close()
157
191
  }
@@ -196,10 +230,20 @@ export function extractSearchText(data: string) {
196
230
 
197
231
  export function makeSnippet(text: string, query: string, radius = 72) {
198
232
  const haystack = text.replace(/\s+/g, " ").trim()
199
- const index = haystack.toLowerCase().indexOf(query.trim().toLowerCase())
200
- if (index === -1) return truncate(haystack, radius * 2)
201
- const start = Math.max(0, index - radius)
202
- const end = Math.min(haystack.length, index + query.length + radius)
233
+ const tokens = query.trim().split(/\s+/)
234
+ const lower = haystack.toLowerCase()
235
+ let searchPos = 0
236
+ let firstIndex = -1
237
+ let lastEnd = 0
238
+ for (const token of tokens) {
239
+ const index = lower.indexOf(token.toLowerCase(), searchPos)
240
+ if (index === -1) return truncate(haystack, radius * 2)
241
+ if (firstIndex === -1) firstIndex = index
242
+ searchPos = index + token.length
243
+ lastEnd = searchPos
244
+ }
245
+ const start = Math.max(0, firstIndex - radius)
246
+ const end = Math.min(haystack.length, lastEnd + radius)
203
247
  return `${start > 0 ? "..." : ""}${haystack.slice(start, end)}${end < haystack.length ? "..." : ""}`
204
248
  }
205
249
 
@@ -217,15 +261,27 @@ function findMatch(text: string, query: string, radius = 96) {
217
261
  excerpt: collapsed.slice(0, end),
218
262
  }
219
263
  }
220
- const start = text.toLowerCase().indexOf(needle.toLowerCase())
221
- if (start === -1) return
222
- const end = start + needle.length
264
+ const tokens = needle.split(/\s+/)
265
+ const lowerText = text.toLowerCase()
266
+ let searchPos = 0
267
+ let firstStart = -1
268
+ let lastEnd = -1
269
+ for (const token of tokens) {
270
+ const pos = lowerText.indexOf(token.toLowerCase(), searchPos)
271
+ if (pos === -1) return
272
+ if (firstStart === -1) firstStart = pos
273
+ searchPos = pos + token.length
274
+ lastEnd = searchPos
275
+ }
276
+ const start = firstStart
277
+ const end = lastEnd
223
278
  const lineStart = text.lastIndexOf("\n", start - 1) + 1
224
279
  const nextLine = text.indexOf("\n", end)
225
280
  const lineEnd = nextLine === -1 ? text.length : nextLine
226
281
  const line = text.slice(lineStart, lineEnd)
282
+ const matchLen = end - start
227
283
  const lineMatchStart = Math.max(0, start - lineStart)
228
- const lineMatchEnd = lineMatchStart + needle.length
284
+ const lineMatchEnd = lineMatchStart + matchLen
229
285
  const excerptStart = Math.max(0, lineMatchStart - radius)
230
286
  const excerptEnd = Math.min(line.length, lineMatchEnd + radius)
231
287
  const before = normalizeSnippetSegment(line.slice(excerptStart, lineMatchStart))
@@ -361,35 +417,35 @@ function parseToolState(value: unknown): ToolState | undefined {
361
417
  }
362
418
  }
363
419
 
364
- function searchRows(db: Database, query: string, limit: number, directory?: string) {
420
+ function searchRows(db: Database, query: string, limit: number, directory?: string, offset?: number) {
365
421
  if (!tableExists(db, "part") || !tableExists(db, "message")) return []
366
- return visibleTextRows(db, limit, query, directory).flatMap((row) => rowToSearchResult(row, query) ?? [])
422
+ return visibleTextRows(db, limit, query, directory, offset).flatMap((row) => rowToSearchResult(row, query) ?? [])
367
423
  }
368
424
 
369
- function visibleTextRows(db: Database, limit: number, query?: string, directory?: string) {
370
- if (query) {
371
- const input = directory ? [directory, `%${query}%`, limit] satisfies [string, string, number] : [`%${query}%`, limit] satisfies [string, number]
372
- return db
373
- .query<Row, [string, string, number] | [string, number]>(`
374
- SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
375
- json_extract(m.data, '$.role') AS role,
376
- p.time_created,
377
- json_extract(p.data, '$.text') AS text
378
- FROM part p
379
- JOIN message m ON m.id = p.message_id
380
- JOIN session s ON s.id = p.session_id
381
- WHERE json_extract(p.data, '$.type') = 'text'
382
- AND json_extract(m.data, '$.role') IN ('user', 'assistant')
383
- ${directory ? "AND s.directory = ?" : ""}
384
- AND json_extract(p.data, '$.text') LIKE ?
385
- ORDER BY p.time_created DESC
386
- LIMIT ?
387
- `)
388
- .all(...input)
425
+ function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number) {
426
+ const offsetClause = offset ? "OFFSET ?" : ""
427
+ const conditions: string[] = [
428
+ "json_extract(p.data, '$.type') = 'text'",
429
+ "json_extract(m.data, '$.role') IN ('user', 'assistant')",
430
+ ]
431
+ const params: (string | number)[] = []
432
+
433
+ if (directory) {
434
+ conditions.push("s.directory = ?")
435
+ params.push(directory)
389
436
  }
390
- const input = directory ? [directory, limit] satisfies [string, number] : [limit] satisfies [number]
437
+
438
+ const tokens = query ? query.trim().split(/\s+/).filter(Boolean) : []
439
+ for (const token of tokens) {
440
+ conditions.push("json_extract(p.data, '$.text') LIKE ?")
441
+ params.push(`%${token}%`)
442
+ }
443
+
444
+ params.push(limit)
445
+ if (offset) params.push(offset)
446
+
391
447
  return db
392
- .query<Row, [string, number] | [number]>(`
448
+ .query<Row, (string | number)[]>(`
393
449
  SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
394
450
  json_extract(m.data, '$.role') AS role,
395
451
  p.time_created,
@@ -397,13 +453,11 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
397
453
  FROM part p
398
454
  JOIN message m ON m.id = p.message_id
399
455
  JOIN session s ON s.id = p.session_id
400
- WHERE json_extract(p.data, '$.type') = 'text'
401
- AND json_extract(m.data, '$.role') IN ('user', 'assistant')
402
- ${directory ? "AND s.directory = ?" : ""}
456
+ WHERE ${conditions.join(" AND ")}
403
457
  ORDER BY p.time_created DESC
404
- LIMIT ?
458
+ LIMIT ? ${offsetClause}
405
459
  `)
406
- .all(...input)
460
+ .all(...params as any[])
407
461
  }
408
462
 
409
463
  function tableExists(db: Database, name: string) {
package/telescope.tsx CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  type ConversationPreviewPart,
14
14
  type SearchResult,
15
15
  } from "./search.ts"
16
+ import { debug } from "./ui/debug.ts"
16
17
  import { syntaxStyle } from "./ui/format.ts"
17
18
  import { isKey, prevent } from "./ui/keyboard.ts"
18
19
  import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
@@ -27,6 +28,14 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
27
28
  const [error, setError] = createSignal("")
28
29
  const [mode, setMode] = createSignal<"normal" | "insert">("normal")
29
30
  const [loading, setLoading] = createSignal(true)
31
+ const [hasMore, setHasMore] = createSignal(true)
32
+ 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 })
30
39
  let input: InputRenderable | undefined
31
40
  let resultScroll: ScrollBoxRenderable | undefined
32
41
  let previewScroll: ScrollBoxRenderable | undefined
@@ -44,31 +53,44 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
44
53
  createEffect(() => {
45
54
  const q = query().trim()
46
55
  setError("")
56
+ setHasMore(true)
57
+ const db = dbPath()
58
+ const dir = directory
59
+
47
60
  if (!q) {
48
61
  setLoading(true)
49
62
  const timer = setTimeout(() => {
63
+ debug.time("query:recent")
50
64
  try {
51
- setResults(recentSessionMessages({ limit: 40, dbPath: dbPath(), directory }))
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)
52
69
  } catch (err) {
53
70
  setResults([])
54
71
  setError(err instanceof Error ? err.message : String(err))
55
72
  }
73
+ debug.timeEnd("query:recent")
56
74
  setLoading(false)
57
- setSelected(0)
58
75
  }, 1)
59
76
  onCleanup(() => clearTimeout(timer))
60
77
  return
61
78
  }
79
+
62
80
  setLoading(true)
63
81
  setBusy(true)
64
82
  const timer = setTimeout(() => {
83
+ debug.time("query:search")
65
84
  try {
66
- setResults(searchSessionMessages(q, { limit: 120, dbPath: dbPath(), directory }))
85
+ const batch = searchSessionMessages(q, { limit: BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
86
+ setResults(batch)
87
+ setHasMore(batch.length >= BATCH_SIZE)
67
88
  setSelected(0)
68
89
  } catch (err) {
69
90
  setResults([])
70
91
  setError(err instanceof Error ? err.message : String(err))
71
92
  } finally {
93
+ debug.timeEnd("query:search")
72
94
  setBusy(false)
73
95
  setLoading(false)
74
96
  }
@@ -78,6 +100,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
78
100
 
79
101
  const move = (delta: number) => {
80
102
  if (results().length === 0) return
103
+ debug.time("nav:total")
81
104
  setSelected((index) => (index + delta + results().length) % results().length)
82
105
  }
83
106
 
@@ -95,24 +118,82 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
95
118
  }
96
119
  })
97
120
 
121
+ createEffect(() => {
122
+ const index = selected()
123
+ const total = results().length
124
+ if (index < total - 3) return
125
+ if (!hasMore() || loadingMore() || busy() || loading()) return
126
+
127
+ const q = query().trim()
128
+ const db = dbPath()
129
+ const dir = directory
130
+
131
+ setLoadingMore(true)
132
+ const timer = setTimeout(() => {
133
+ debug.time("query:load-more")
134
+ try {
135
+ const batch = q
136
+ ? searchSessionMessages(q, { limit: BATCH_SIZE, offset: total, dbPath: db, directory: dir })
137
+ : recentSessionMessages({ limit: RECENT_BATCH_SIZE, offset: total, dbPath: db, directory: dir })
138
+ setResults((prev) => [...prev, ...batch])
139
+ if (batch.length < (q ? BATCH_SIZE : RECENT_BATCH_SIZE)) setHasMore(false)
140
+ } catch {
141
+ // keep existing results on error
142
+ } finally {
143
+ debug.timeEnd("query:load-more")
144
+ setLoadingMore(false)
145
+ }
146
+ }, 100)
147
+ onCleanup(() => clearTimeout(timer))
148
+ })
149
+
150
+ let lastPreviewItemId = ""
98
151
  createEffect(() => {
99
152
  const item = selectedResult()
153
+ const range = previewRange()
100
154
  if (!item) {
101
155
  setPreviewParts([])
102
156
  return
103
157
  }
104
- try {
105
- setPreviewParts(loadConversationWindow(item, { before: 12, after: 24, dbPath: dbPath() }))
106
- } catch {
107
- setPreviewParts([])
158
+ if (item.id !== lastPreviewItemId) {
159
+ lastPreviewItemId = item.id
160
+ debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
161
+ setPreviewRange({ before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
162
+ return
108
163
  }
164
+ const db = dbPath()
165
+ debug.time("preview:load")
166
+ try {
167
+ setPreviewParts(loadConversationWindow(item, { before: range.before, after: range.after, dbPath: db }))
168
+ } catch {}
169
+ debug.timeEnd("nav:total")
170
+ debug.timeEnd("preview:load")
171
+ })
172
+
173
+ createEffect(() => {
174
+ const item = selectedResult()
175
+ if (!item) return
176
+ const interval = setInterval(() => {
177
+ const scroll = previewScroll
178
+ const children = scroll?.getChildren()
179
+ if (!scroll || !children || children.length === 0) return
180
+ const range = previewRange()
181
+ const lastChild = children[children.length - 1] as { y: number; height: number }
182
+ const totalContentHeight = lastChild.y + lastChild.height
183
+ const atTop = scroll.y <= 0 && range.before > 0
184
+ const atBottom = scroll.y + scroll.height >= totalContentHeight - 1
185
+ if (atTop) setPreviewRange((prev) => ({ ...prev, before: prev.before + PREVIEW_EXPAND_STEP }))
186
+ if (atBottom) setPreviewRange((prev) => ({ ...prev, after: prev.after + PREVIEW_EXPAND_STEP }))
187
+ }, 400)
188
+ onCleanup(() => clearInterval(interval))
109
189
  })
110
190
 
111
191
  createEffect(() => {
112
192
  const item = selectedResult()
113
193
  previewParts()
114
194
  if (!item) return
115
- setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
195
+ const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
196
+ onCleanup(() => clearTimeout(timer))
116
197
  })
117
198
 
118
199
  const open = () => {
@@ -256,6 +337,12 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
256
337
  />
257
338
  )}
258
339
  </For>
340
+ <Show when={loadingMore()}>
341
+ <SkeletonRow theme={theme()} />
342
+ </Show>
343
+ <Show when={!hasMore() && results().length > 0}>
344
+ <text fg={theme().textMuted}> no more results</text>
345
+ </Show>
259
346
  </Show>
260
347
  </Show>
261
348
  <Show when={loading()}>
package/tsconfig.json CHANGED
@@ -13,5 +13,6 @@
13
13
  "skipLibCheck": true,
14
14
  "types": ["bun"]
15
15
  },
16
- "include": ["**/*.ts", "**/*.tsx"]
16
+ "include": ["**/*.ts", "**/*.tsx"],
17
+ "exclude": ["opencode-smart-session-picker/**"]
17
18
  }
package/ui/debug.ts ADDED
@@ -0,0 +1,25 @@
1
+ const enabled = process.env.OPENCODE_TELESCOPE_DEBUG === "1"
2
+ const timers = new Map<string, number>()
3
+
4
+ export const debug = {
5
+ get enabled() {
6
+ return enabled
7
+ },
8
+
9
+ time(label: string) {
10
+ if (enabled) timers.set(label, performance.now())
11
+ },
12
+
13
+ timeEnd(label: string) {
14
+ if (!enabled) return
15
+ const start = timers.get(label)
16
+ if (start !== undefined) {
17
+ console.log(`[telescope:time] ${label} ${(performance.now() - start).toFixed(2)}ms`)
18
+ timers.delete(label)
19
+ }
20
+ },
21
+
22
+ log(...args: unknown[]) {
23
+ if (enabled) console.log("[telescope]", ...args)
24
+ },
25
+ }