@bojackduy/opencode-telescope 0.1.9 → 0.1.12

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
@@ -4,7 +4,7 @@ Fuzzy search across all your OpenCode conversations — grep through session and
4
4
 
5
5
  > Inspired by [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) — a fuzzy finder for your conversation history.
6
6
 
7
- ![Demo](./assets/demo.svg)
7
+ ![Demo](./assets/demo.png)
8
8
 
9
9
  ## Use cases
10
10
 
@@ -38,13 +38,13 @@ Add the plugin to your `tui.json`:
38
38
 
39
39
  ## Usage
40
40
 
41
- | Action | Key / Command |
42
- |--------|--------------|
43
- | Open search | `<leader>f` or `/telescope` |
44
- | Type to filter | Fuzzy match against conversation text |
45
- | Navigate results | `↑` / `↓` or `Ctrl+j` / `Ctrl+k` |
46
- | Preview | Select a result to see the conversation preview |
47
- | Open | Press `Enter` to jump to the selected session |
41
+ | Action | Key / Command |
42
+ | ---------------- | ----------------------------------------------- |
43
+ | Open search | `<leader>f` or `/telescope` |
44
+ | Type to filter | Fuzzy match against conversation text |
45
+ | Navigate results | `↑` / `↓` or `Ctrl+j` / `Ctrl+k` |
46
+ | Preview | Select a result to see the conversation preview |
47
+ | Open | Press `Enter` to jump to the selected session |
48
48
 
49
49
  ## How it works
50
50
 
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-telescope",
4
- "version": "0.1.9",
4
+ "version": "0.1.12",
5
5
  "description": "Fuzzy search across all OpenCode conversations — grep session and chat history, find code snippets, and jump to any chat instantly",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -63,11 +63,11 @@
63
63
  "solid-js": "*"
64
64
  },
65
65
  "devDependencies": {
66
- "@opencode-ai/plugin": "^1.14.28",
67
- "@opentui/core": "^0.1.105",
68
- "@opentui/solid": "^0.1.105",
69
- "@types/bun": "^1.3.13",
70
- "solid-js": "^1.9.12",
71
- "typescript": "^5.9.3"
66
+ "@opencode-ai/plugin": "^1.17.9",
67
+ "@opentui/core": "^0.4.1",
68
+ "@opentui/solid": "^0.4.1",
69
+ "@types/bun": "^1.3.14",
70
+ "solid-js": "^1.9.13",
71
+ "typescript": "^6.0.3"
72
72
  }
73
73
  }
package/search.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Database } from "bun:sqlite"
2
- import { existsSync, readdirSync } from "node:fs"
2
+ import { existsSync, readdirSync, statSync } from "node:fs"
3
3
  import { homedir } from "node:os"
4
4
  import path from "node:path"
5
5
  import { debug } from "./ui/debug.ts"
@@ -69,6 +69,21 @@ type ConversationRow = {
69
69
  }
70
70
 
71
71
  let cachedDbPath: string | undefined
72
+ let _db: Database | undefined
73
+ let _dbPath: string | undefined
74
+ let _indexDb: Database | undefined
75
+ let _indexDbPath: string | undefined
76
+
77
+ function getDb(dbPath?: string): Database {
78
+ const resolved = dbPath ?? resolveDatabasePath()
79
+ if (!_db || resolved !== _dbPath) {
80
+ _db?.close()
81
+ _db = new Database(resolved, { readonly: true })
82
+ _dbPath = resolved
83
+ tableCache.clear()
84
+ }
85
+ return _db
86
+ }
72
87
 
73
88
  export function resolveDatabasePath() {
74
89
  if (cachedDbPath) return cachedDbPath
@@ -105,90 +120,79 @@ export function searchSessionMessages(query: string, options?: { limit?: number;
105
120
  const term = query.trim()
106
121
  if (!term) return []
107
122
  if (options?.dbPath === ":memory:") return []
108
- const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
109
- try {
110
- return searchRows(db, term, options?.limit ?? 80, options?.directory, options?.offset)
111
- } finally {
112
- db.close()
113
- }
123
+ const dbPath = options?.dbPath ?? resolveDatabasePath()
124
+ const db = getDb(dbPath)
125
+ return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset)
114
126
  }
115
127
 
116
128
  export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
117
- const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
118
- try {
119
- return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset).flatMap(
120
- (row) => rowToSearchResult(row, "") ?? [],
121
- )
122
- } finally {
123
- db.close()
124
- }
129
+ const db = getDb(options?.dbPath)
130
+ return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset).flatMap(
131
+ (row) => rowToSearchResult(row, "") ?? [],
132
+ )
125
133
  }
126
134
 
127
135
  export function loadConversationWindow(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }) {
128
136
  debug.time("preview:query:total")
129
- const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
130
- try {
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")
137
+ const db = getDb(options?.dbPath)
138
+ const before = options?.before ?? 3
139
+ const after = options?.after ?? 6
140
+
141
+ debug.time("preview:query:hit")
142
+ const hit = db.query<{ time_created: number }, [string]>(
143
+ "SELECT time_created FROM part WHERE id = ?",
144
+ ).get(result.id)
145
+ debug.timeEnd("preview:query:hit")
146
+ if (!hit) {
187
147
  debug.timeEnd("preview:query:total")
188
- return parts
189
- } finally {
190
- db.close()
148
+ return []
191
149
  }
150
+
151
+ const fetchBefore = Math.max(before * 4, 30)
152
+ const fetchAfter = Math.max(after * 4, 50)
153
+
154
+ debug.time("preview:query:before")
155
+ const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
156
+ SELECT p.id, p.message_id, p.session_id,
157
+ json_extract(m.data, '$.role') AS role,
158
+ json_extract(p.data, '$.type') AS type,
159
+ p.time_created, p.data
160
+ FROM part p
161
+ JOIN message m ON m.id = p.message_id
162
+ WHERE p.session_id = ?
163
+ AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
164
+ ORDER BY p.time_created DESC, p.id DESC
165
+ LIMIT ?
166
+ `).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchBefore)
167
+ debug.timeEnd("preview:query:before")
168
+
169
+ debug.time("preview:query:after")
170
+ const afterRows = db.query<ConversationRow, [string, number, number, string, number]>(`
171
+ SELECT p.id, p.message_id, p.session_id,
172
+ json_extract(m.data, '$.role') AS role,
173
+ json_extract(p.data, '$.type') AS type,
174
+ p.time_created, p.data
175
+ FROM part p
176
+ JOIN message m ON m.id = p.message_id
177
+ WHERE p.session_id = ?
178
+ AND (p.time_created > ? OR (p.time_created = ? AND p.id >= ?))
179
+ ORDER BY p.time_created ASC, p.id ASC
180
+ LIMIT ?
181
+ `).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchAfter)
182
+ debug.timeEnd("preview:query:after")
183
+
184
+ debug.time("preview:query:parse")
185
+ const isValid = (row: ConversationRow) =>
186
+ (row.role === "user" || row.role === "assistant") &&
187
+ (row.type === "text" || row.type === "reasoning" || row.type === "tool")
188
+
189
+ const parts = [
190
+ ...beforeRows.filter(isValid).slice(0, before).reverse(),
191
+ ...afterRows.filter(isValid).slice(0, after + 1),
192
+ ].flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
193
+ debug.timeEnd("preview:query:parse")
194
+ debug.timeEnd("preview:query:total")
195
+ return parts
192
196
  }
193
197
 
194
198
  export function rowToSearchResult(row: Row, query: string): SearchResult | undefined {
@@ -417,9 +421,46 @@ function parseToolState(value: unknown): ToolState | undefined {
417
421
  }
418
422
  }
419
423
 
420
- function searchRows(db: Database, query: string, limit: number, directory?: string, offset?: number) {
424
+ function searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number) {
421
425
  if (!tableExists(db, "part") || !tableExists(db, "message")) return []
422
- return visibleTextRows(db, limit, query, directory, offset).flatMap((row) => rowToSearchResult(row, query) ?? [])
426
+ debug.time("query:sql")
427
+ const rows = indexedTextRows(db, dbPath, limit, query, directory, offset) ?? visibleTextRows(db, limit, query, directory, offset)
428
+ debug.timeEnd("query:sql")
429
+ debug.time("query:map")
430
+ const results = rows.flatMap((row) => rowToSearchResult(row, query) ?? [])
431
+ debug.timeEnd("query:map")
432
+ return results
433
+ }
434
+
435
+ function indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number) {
436
+ const match = ftsQuery(query)
437
+ if (!match) return []
438
+ try {
439
+ const index = ensureSearchIndex(db, dbPath)
440
+ const conditions = ["document_fts MATCH ?"]
441
+ const params: (string | number)[] = [match]
442
+ if (directory) {
443
+ conditions.push("directory = ?")
444
+ params.push(directory)
445
+ }
446
+ params.push(limit)
447
+ if (offset) params.push(offset)
448
+ const offsetClause = offset ? "OFFSET ?" : ""
449
+ debug.time("query:fts:exec")
450
+ const rows = index.query<Row, (string | number)[]>(`
451
+ SELECT id, message_id, session_id, session_title, directory, role,
452
+ CAST(time_created AS INTEGER) AS time_created, text
453
+ FROM document_fts
454
+ WHERE ${conditions.join(" AND ")}
455
+ ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
456
+ LIMIT ? ${offsetClause}
457
+ `).all(...params as any[])
458
+ debug.timeEnd("query:fts:exec")
459
+ return rows
460
+ } catch (err) {
461
+ debug.log("fts:fallback", err instanceof Error ? err.message : String(err))
462
+ return
463
+ }
423
464
  }
424
465
 
425
466
  function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number) {
@@ -444,24 +485,145 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
444
485
  params.push(limit)
445
486
  if (offset) params.push(offset)
446
487
 
447
- return db
448
- .query<Row, (string | number)[]>(`
449
- SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
450
- json_extract(m.data, '$.role') AS role,
451
- p.time_created,
452
- json_extract(p.data, '$.text') AS text
453
- FROM part p
454
- JOIN message m ON m.id = p.message_id
455
- JOIN session s ON s.id = p.session_id
456
- WHERE ${conditions.join(" AND ")}
457
- ORDER BY p.time_created DESC
458
- LIMIT ? ${offsetClause}
459
- `)
460
- .all(...params as any[])
488
+ const sql = `
489
+ SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
490
+ json_extract(m.data, '$.role') AS role,
491
+ p.time_created,
492
+ json_extract(p.data, '$.text') AS text
493
+ FROM part p
494
+ JOIN message m ON m.id = p.message_id
495
+ JOIN session s ON s.id = p.session_id
496
+ WHERE ${conditions.join(" AND ")}
497
+ ORDER BY p.time_created DESC
498
+ LIMIT ? ${offsetClause}
499
+ `
500
+ debug.time("query:sql:exec")
501
+ const rows = db.query<Row, (string | number)[]>(sql).all(...params as any[])
502
+ debug.timeEnd("query:sql:exec")
503
+ return rows
504
+ }
505
+
506
+ function ensureSearchIndex(source: Database, sourcePath: string) {
507
+ const indexPath = searchIndexPath(sourcePath)
508
+ if (!_indexDb || _indexDbPath !== indexPath) {
509
+ _indexDb?.close()
510
+ _indexDb = new Database(indexPath)
511
+ _indexDbPath = indexPath
512
+ migrateSearchIndex(_indexDb)
513
+ }
514
+
515
+ const state = sourceState(source, sourcePath)
516
+ const currentDataVersion = getMeta(_indexDb, "source_data_version")
517
+ const currentMtimeMs = getMeta(_indexDb, "source_mtime_ms")
518
+ const currentPath = getMeta(_indexDb, "source_path")
519
+ if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs)) {
520
+ rebuildSearchIndex(source, _indexDb, sourcePath, state)
521
+ }
522
+ return _indexDb
523
+ }
524
+
525
+ function searchIndexPath(sourcePath: string) {
526
+ const parsed = path.parse(sourcePath)
527
+ return path.join(parsed.dir, `${parsed.name}-search.db`)
528
+ }
529
+
530
+ function migrateSearchIndex(db: Database) {
531
+ db.exec(`
532
+ CREATE TABLE IF NOT EXISTS index_meta(
533
+ key TEXT PRIMARY KEY,
534
+ value TEXT NOT NULL
535
+ );
536
+ CREATE VIRTUAL TABLE IF NOT EXISTS document_fts USING fts5(
537
+ id UNINDEXED,
538
+ message_id UNINDEXED,
539
+ session_id UNINDEXED,
540
+ session_title,
541
+ directory UNINDEXED,
542
+ role UNINDEXED,
543
+ time_created UNINDEXED,
544
+ text,
545
+ tokenize='unicode61'
546
+ );
547
+ `)
548
+ }
549
+
550
+ function sourceState(db: Database, sourcePath: string) {
551
+ const stat = statSync(sourcePath)
552
+ const dataVersion = db.query<{ data_version: number }, []>("PRAGMA data_version").get()?.data_version ?? 0
553
+ return { dataVersion, mtimeMs: stat.mtimeMs }
554
+ }
555
+
556
+ function rebuildSearchIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
557
+ debug.time("fts:rebuild")
558
+ const rows = source.query<Row, []>(`
559
+ SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
560
+ json_extract(m.data, '$.role') AS role,
561
+ p.time_created,
562
+ json_extract(p.data, '$.text') AS text
563
+ FROM part p
564
+ JOIN message m ON m.id = p.message_id
565
+ JOIN session s ON s.id = p.session_id
566
+ WHERE json_extract(p.data, '$.type') = 'text'
567
+ AND json_extract(m.data, '$.role') IN ('user', 'assistant')
568
+ AND json_extract(p.data, '$.text') IS NOT NULL
569
+ ORDER BY p.time_created DESC
570
+ `).all()
571
+ const insert = index.query<Row, [string, string, string, string, string, string, number, string]>(`
572
+ INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, time_created, text)
573
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
574
+ `)
575
+ index.exec("BEGIN IMMEDIATE")
576
+ try {
577
+ index.exec("DELETE FROM document_fts")
578
+ for (const row of rows) {
579
+ insert.run(
580
+ row.id,
581
+ row.message_id,
582
+ row.session_id,
583
+ row.session_title ?? "Untitled session",
584
+ row.directory,
585
+ row.role,
586
+ row.time_created,
587
+ row.text,
588
+ )
589
+ }
590
+ setMeta(index, "source_path", sourcePath)
591
+ setMeta(index, "source_data_version", String(state.dataVersion))
592
+ setMeta(index, "source_mtime_ms", String(state.mtimeMs))
593
+ index.exec("COMMIT")
594
+ } catch (err) {
595
+ index.exec("ROLLBACK")
596
+ throw err
597
+ } finally {
598
+ debug.timeEnd("fts:rebuild")
599
+ }
600
+ }
601
+
602
+ function ftsQuery(query: string) {
603
+ const tokens = query.trim().split(/\s+/)
604
+ .map((token) => token.replace(/["*^:()]/g, " ").trim())
605
+ .filter(Boolean)
606
+ if (!tokens.length) return ""
607
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"*`).join(" AND ")
608
+ }
609
+
610
+ function getMeta(db: Database, key: string) {
611
+ return db.query<{ value: string }, [string]>("SELECT value FROM index_meta WHERE key = ?").get(key)?.value
612
+ }
613
+
614
+ function setMeta(db: Database, key: string, value: string) {
615
+ db.query<unknown, [string, string]>(`
616
+ INSERT INTO index_meta(key, value) VALUES (?, ?)
617
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
618
+ `).run(key, value)
461
619
  }
462
620
 
621
+ const tableCache = new Map<string, boolean>()
463
622
  function tableExists(db: Database, name: string) {
464
- return Boolean(db.query<{ name: string }, [string]>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name))
623
+ if (tableCache.has(name)) return tableCache.get(name)!
624
+ const exists = Boolean(db.query<{ name: string }, [string]>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name))
625
+ tableCache.set(name, exists)
626
+ return exists
465
627
  }
466
628
 
467
629
  function defaultDataDir() {
package/telescope.tsx CHANGED
@@ -100,8 +100,15 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
100
100
 
101
101
  const move = (delta: number) => {
102
102
  if (results().length === 0) return
103
- debug.time("nav:total")
104
- setSelected((index) => (index + delta + results().length) % results().length)
103
+ setSelected((index) => {
104
+ const next = index + delta
105
+ let finalIndex = next
106
+ if (next < 0) finalIndex = results().length - 1
107
+ else if (next >= results().length) finalIndex = results().length - 1
108
+
109
+ if (finalIndex !== index) debug.time("nav:total")
110
+ return finalIndex
111
+ })
105
112
  }
106
113
 
107
114
  createEffect(() => {
@@ -144,7 +151,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
144
151
  setLoadingMore(false)
145
152
  }
146
153
  }, 100)
147
- onCleanup(() => clearTimeout(timer))
154
+ onCleanup(() => { clearTimeout(timer); setLoadingMore(false) })
148
155
  })
149
156
 
150
157
  let lastPreviewItemId = ""
@@ -188,10 +195,13 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
188
195
  onCleanup(() => clearInterval(interval))
189
196
  })
190
197
 
198
+ let scrolledItem = ""
191
199
  createEffect(() => {
192
200
  const item = selectedResult()
193
201
  previewParts()
194
202
  if (!item) return
203
+ if (item.id === scrolledItem) return
204
+ scrolledItem = item.id
195
205
  const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
196
206
  onCleanup(() => clearTimeout(timer))
197
207
  })