@bojackduy/opencode-telescope 0.1.10 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-telescope",
4
- "version": "0.1.10",
4
+ "version": "0.1.13",
5
5
  "description": "Fuzzy search across all OpenCode conversations — grep session and chat history, find code snippets, and jump to any chat instantly",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -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"
@@ -40,6 +40,17 @@ export type ConversationPreviewPart = {
40
40
  target: boolean
41
41
  }
42
42
 
43
+ export type ConversationPreviewPage = {
44
+ parts: ConversationPreviewPart[]
45
+ hasMoreBefore: boolean
46
+ hasMoreAfter: boolean
47
+ }
48
+
49
+ export type ConversationPreviewCursor = {
50
+ id: string
51
+ timeCreated: number
52
+ }
53
+
43
54
  export type ToolState = {
44
55
  status: "pending" | "running" | "completed" | "error"
45
56
  input?: unknown
@@ -69,6 +80,21 @@ type ConversationRow = {
69
80
  }
70
81
 
71
82
  let cachedDbPath: string | undefined
83
+ let _db: Database | undefined
84
+ let _dbPath: string | undefined
85
+ let _indexDb: Database | undefined
86
+ let _indexDbPath: string | undefined
87
+
88
+ function getDb(dbPath?: string): Database {
89
+ const resolved = dbPath ?? resolveDatabasePath()
90
+ if (!_db || resolved !== _dbPath) {
91
+ _db?.close()
92
+ _db = new Database(resolved, { readonly: true })
93
+ _dbPath = resolved
94
+ tableCache.clear()
95
+ }
96
+ return _db
97
+ }
72
98
 
73
99
  export function resolveDatabasePath() {
74
100
  if (cachedDbPath) return cachedDbPath
@@ -105,90 +131,177 @@ export function searchSessionMessages(query: string, options?: { limit?: number;
105
131
  const term = query.trim()
106
132
  if (!term) return []
107
133
  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
- }
134
+ const dbPath = options?.dbPath ?? resolveDatabasePath()
135
+ const db = getDb(dbPath)
136
+ return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset)
114
137
  }
115
138
 
116
139
  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
- }
140
+ const db = getDb(options?.dbPath)
141
+ return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset).flatMap(
142
+ (row) => rowToSearchResult(row, "") ?? [],
143
+ )
125
144
  }
126
145
 
127
- export function loadConversationWindow(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }) {
146
+ export function loadConversationAround(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }): ConversationPreviewPage {
128
147
  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")
148
+ const db = getDb(options?.dbPath)
149
+ const before = options?.before ?? 3
150
+ const after = options?.after ?? 6
151
+
152
+ debug.time("preview:query:hit")
153
+ const hit = db.query<{ time_created: number }, [string]>(
154
+ "SELECT time_created FROM part WHERE id = ?",
155
+ ).get(result.id)
156
+ debug.timeEnd("preview:query:hit")
157
+ if (!hit) {
158
+ debug.log("preview:window", {
159
+ item: result.id,
160
+ session: result.sessionID,
161
+ before,
162
+ after,
163
+ hit: false,
164
+ })
187
165
  debug.timeEnd("preview:query:total")
188
- return parts
189
- } finally {
190
- db.close()
166
+ return { parts: [], hasMoreBefore: false, hasMoreAfter: false }
167
+ }
168
+
169
+ const fetchBefore = Math.max(before * 4, before + 1, 30)
170
+ const fetchAfter = Math.max((after + 1) * 4, after + 2, 50)
171
+
172
+ debug.time("preview:query:before")
173
+ const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
174
+ SELECT p.id, p.message_id, p.session_id,
175
+ json_extract(m.data, '$.role') AS role,
176
+ json_extract(p.data, '$.type') AS type,
177
+ p.time_created, p.data
178
+ FROM part p
179
+ JOIN message m ON m.id = p.message_id
180
+ WHERE p.session_id = ?
181
+ AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
182
+ ORDER BY p.time_created DESC, p.id DESC
183
+ LIMIT ?
184
+ `).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchBefore)
185
+ debug.timeEnd("preview:query:before")
186
+
187
+ debug.time("preview:query:after")
188
+ const afterRows = db.query<ConversationRow, [string, number, number, string, number]>(`
189
+ SELECT p.id, p.message_id, p.session_id,
190
+ json_extract(m.data, '$.role') AS role,
191
+ json_extract(p.data, '$.type') AS type,
192
+ p.time_created, p.data
193
+ FROM part p
194
+ JOIN message m ON m.id = p.message_id
195
+ WHERE p.session_id = ?
196
+ AND (p.time_created > ? OR (p.time_created = ? AND p.id >= ?))
197
+ ORDER BY p.time_created ASC, p.id ASC
198
+ LIMIT ?
199
+ `).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchAfter)
200
+ debug.timeEnd("preview:query:after")
201
+
202
+ debug.time("preview:query:parse")
203
+ const validBefore = beforeRows.filter(isPreviewRow)
204
+ const validAfter = afterRows.filter(isPreviewRow)
205
+ const parts = [
206
+ ...validBefore.slice(0, before).reverse(),
207
+ ...validAfter.slice(0, after + 1),
208
+ ].flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
209
+ const page = {
210
+ parts,
211
+ hasMoreBefore: validBefore.length > before,
212
+ hasMoreAfter: validAfter.length > after + 1,
191
213
  }
214
+ debug.log("preview:window", {
215
+ item: result.id,
216
+ session: result.sessionID,
217
+ mode: "around",
218
+ before,
219
+ after,
220
+ fetchBefore,
221
+ fetchAfter,
222
+ beforeRows: beforeRows.length,
223
+ afterRows: afterRows.length,
224
+ parts: parts.length,
225
+ hasMoreBefore: page.hasMoreBefore,
226
+ hasMoreAfter: page.hasMoreAfter,
227
+ first: parts[0]?.id,
228
+ last: parts.at(-1)?.id,
229
+ })
230
+ debug.timeEnd("preview:query:parse")
231
+ debug.timeEnd("preview:query:total")
232
+ return page
233
+ }
234
+
235
+ export function loadConversationBefore(result: SearchResult, cursor: ConversationPreviewCursor, options?: { limit?: number; dbPath?: string }) {
236
+ debug.time("preview:query:before-page")
237
+ const db = getDb(options?.dbPath)
238
+ const limit = options?.limit ?? 20
239
+ const fetchLimit = Math.max(limit * 4, limit + 1, 30)
240
+ const rows = db.query<ConversationRow, [string, number, number, string, number]>(`
241
+ SELECT p.id, p.message_id, p.session_id,
242
+ json_extract(m.data, '$.role') AS role,
243
+ json_extract(p.data, '$.type') AS type,
244
+ p.time_created, p.data
245
+ FROM part p
246
+ JOIN message m ON m.id = p.message_id
247
+ WHERE p.session_id = ?
248
+ AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
249
+ ORDER BY p.time_created DESC, p.id DESC
250
+ LIMIT ?
251
+ `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
252
+ const valid = rows.filter(isPreviewRow)
253
+ const parts = valid.slice(0, limit).reverse().flatMap((row) => parseConversationPart(row, false) ?? [])
254
+ const page = { parts, hasMoreBefore: valid.length > limit }
255
+ debug.log("preview:window", {
256
+ item: result.id,
257
+ session: result.sessionID,
258
+ mode: "before",
259
+ cursor,
260
+ limit,
261
+ rows: rows.length,
262
+ parts: parts.length,
263
+ hasMoreBefore: page.hasMoreBefore,
264
+ first: parts[0]?.id,
265
+ last: parts.at(-1)?.id,
266
+ })
267
+ debug.timeEnd("preview:query:before-page")
268
+ return page
269
+ }
270
+
271
+ export function loadConversationAfter(result: SearchResult, cursor: ConversationPreviewCursor, options?: { limit?: number; dbPath?: string }) {
272
+ debug.time("preview:query:after-page")
273
+ const db = getDb(options?.dbPath)
274
+ const limit = options?.limit ?? 20
275
+ const fetchLimit = Math.max(limit * 4, limit + 1, 30)
276
+ const rows = db.query<ConversationRow, [string, number, number, string, number]>(`
277
+ SELECT p.id, p.message_id, p.session_id,
278
+ json_extract(m.data, '$.role') AS role,
279
+ json_extract(p.data, '$.type') AS type,
280
+ p.time_created, p.data
281
+ FROM part p
282
+ JOIN message m ON m.id = p.message_id
283
+ WHERE p.session_id = ?
284
+ AND (p.time_created > ? OR (p.time_created = ? AND p.id > ?))
285
+ ORDER BY p.time_created ASC, p.id ASC
286
+ LIMIT ?
287
+ `).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
288
+ const valid = rows.filter(isPreviewRow)
289
+ const parts = valid.slice(0, limit).flatMap((row) => parseConversationPart(row, false) ?? [])
290
+ const page = { parts, hasMoreAfter: valid.length > limit }
291
+ debug.log("preview:window", {
292
+ item: result.id,
293
+ session: result.sessionID,
294
+ mode: "after",
295
+ cursor,
296
+ limit,
297
+ rows: rows.length,
298
+ parts: parts.length,
299
+ hasMoreAfter: page.hasMoreAfter,
300
+ first: parts[0]?.id,
301
+ last: parts.at(-1)?.id,
302
+ })
303
+ debug.timeEnd("preview:query:after-page")
304
+ return page
192
305
  }
193
306
 
194
307
  export function rowToSearchResult(row: Row, query: string): SearchResult | undefined {
@@ -395,6 +508,11 @@ function parseConversationPart(row: ConversationRow, target: boolean): Conversat
395
508
  }
396
509
  }
397
510
 
511
+ function isPreviewRow(row: ConversationRow) {
512
+ return (row.role === "user" || row.role === "assistant") &&
513
+ (row.type === "text" || row.type === "reasoning" || row.type === "tool")
514
+ }
515
+
398
516
  function parsePartData(data: string) {
399
517
  try {
400
518
  const value = JSON.parse(data) as unknown
@@ -417,9 +535,46 @@ function parseToolState(value: unknown): ToolState | undefined {
417
535
  }
418
536
  }
419
537
 
420
- function searchRows(db: Database, query: string, limit: number, directory?: string, offset?: number) {
538
+ function searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number) {
421
539
  if (!tableExists(db, "part") || !tableExists(db, "message")) return []
422
- return visibleTextRows(db, limit, query, directory, offset).flatMap((row) => rowToSearchResult(row, query) ?? [])
540
+ debug.time("query:sql")
541
+ const rows = indexedTextRows(db, dbPath, limit, query, directory, offset) ?? visibleTextRows(db, limit, query, directory, offset)
542
+ debug.timeEnd("query:sql")
543
+ debug.time("query:map")
544
+ const results = rows.flatMap((row) => rowToSearchResult(row, query) ?? [])
545
+ debug.timeEnd("query:map")
546
+ return results
547
+ }
548
+
549
+ function indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number) {
550
+ const match = ftsQuery(query)
551
+ if (!match) return []
552
+ try {
553
+ const index = ensureSearchIndex(db, dbPath)
554
+ const conditions = ["document_fts MATCH ?"]
555
+ const params: (string | number)[] = [match]
556
+ if (directory) {
557
+ conditions.push("directory = ?")
558
+ params.push(directory)
559
+ }
560
+ params.push(limit)
561
+ if (offset) params.push(offset)
562
+ const offsetClause = offset ? "OFFSET ?" : ""
563
+ debug.time("query:fts:exec")
564
+ const rows = index.query<Row, (string | number)[]>(`
565
+ SELECT id, message_id, session_id, session_title, directory, role,
566
+ CAST(time_created AS INTEGER) AS time_created, text
567
+ FROM document_fts
568
+ WHERE ${conditions.join(" AND ")}
569
+ ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
570
+ LIMIT ? ${offsetClause}
571
+ `).all(...params as any[])
572
+ debug.timeEnd("query:fts:exec")
573
+ return rows
574
+ } catch (err) {
575
+ debug.log("fts:fallback", err instanceof Error ? err.message : String(err))
576
+ return
577
+ }
423
578
  }
424
579
 
425
580
  function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number) {
@@ -444,24 +599,145 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
444
599
  params.push(limit)
445
600
  if (offset) params.push(offset)
446
601
 
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[])
602
+ const sql = `
603
+ SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
604
+ json_extract(m.data, '$.role') AS role,
605
+ p.time_created,
606
+ json_extract(p.data, '$.text') AS text
607
+ FROM part p
608
+ JOIN message m ON m.id = p.message_id
609
+ JOIN session s ON s.id = p.session_id
610
+ WHERE ${conditions.join(" AND ")}
611
+ ORDER BY p.time_created DESC
612
+ LIMIT ? ${offsetClause}
613
+ `
614
+ debug.time("query:sql:exec")
615
+ const rows = db.query<Row, (string | number)[]>(sql).all(...params as any[])
616
+ debug.timeEnd("query:sql:exec")
617
+ return rows
618
+ }
619
+
620
+ function ensureSearchIndex(source: Database, sourcePath: string) {
621
+ const indexPath = searchIndexPath(sourcePath)
622
+ if (!_indexDb || _indexDbPath !== indexPath) {
623
+ _indexDb?.close()
624
+ _indexDb = new Database(indexPath)
625
+ _indexDbPath = indexPath
626
+ migrateSearchIndex(_indexDb)
627
+ }
628
+
629
+ const state = sourceState(source, sourcePath)
630
+ const currentDataVersion = getMeta(_indexDb, "source_data_version")
631
+ const currentMtimeMs = getMeta(_indexDb, "source_mtime_ms")
632
+ const currentPath = getMeta(_indexDb, "source_path")
633
+ if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs)) {
634
+ rebuildSearchIndex(source, _indexDb, sourcePath, state)
635
+ }
636
+ return _indexDb
637
+ }
638
+
639
+ function searchIndexPath(sourcePath: string) {
640
+ const parsed = path.parse(sourcePath)
641
+ return path.join(parsed.dir, `${parsed.name}-search.db`)
642
+ }
643
+
644
+ function migrateSearchIndex(db: Database) {
645
+ db.exec(`
646
+ CREATE TABLE IF NOT EXISTS index_meta(
647
+ key TEXT PRIMARY KEY,
648
+ value TEXT NOT NULL
649
+ );
650
+ CREATE VIRTUAL TABLE IF NOT EXISTS document_fts USING fts5(
651
+ id UNINDEXED,
652
+ message_id UNINDEXED,
653
+ session_id UNINDEXED,
654
+ session_title,
655
+ directory UNINDEXED,
656
+ role UNINDEXED,
657
+ time_created UNINDEXED,
658
+ text,
659
+ tokenize='unicode61'
660
+ );
661
+ `)
662
+ }
663
+
664
+ function sourceState(db: Database, sourcePath: string) {
665
+ const stat = statSync(sourcePath)
666
+ const dataVersion = db.query<{ data_version: number }, []>("PRAGMA data_version").get()?.data_version ?? 0
667
+ return { dataVersion, mtimeMs: stat.mtimeMs }
668
+ }
669
+
670
+ function rebuildSearchIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
671
+ debug.time("fts:rebuild")
672
+ const rows = source.query<Row, []>(`
673
+ SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
674
+ json_extract(m.data, '$.role') AS role,
675
+ p.time_created,
676
+ json_extract(p.data, '$.text') AS text
677
+ FROM part p
678
+ JOIN message m ON m.id = p.message_id
679
+ JOIN session s ON s.id = p.session_id
680
+ WHERE json_extract(p.data, '$.type') = 'text'
681
+ AND json_extract(m.data, '$.role') IN ('user', 'assistant')
682
+ AND json_extract(p.data, '$.text') IS NOT NULL
683
+ ORDER BY p.time_created DESC
684
+ `).all()
685
+ const insert = index.query<Row, [string, string, string, string, string, string, number, string]>(`
686
+ INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, time_created, text)
687
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
688
+ `)
689
+ index.exec("BEGIN IMMEDIATE")
690
+ try {
691
+ index.exec("DELETE FROM document_fts")
692
+ for (const row of rows) {
693
+ insert.run(
694
+ row.id,
695
+ row.message_id,
696
+ row.session_id,
697
+ row.session_title ?? "Untitled session",
698
+ row.directory,
699
+ row.role,
700
+ row.time_created,
701
+ row.text,
702
+ )
703
+ }
704
+ setMeta(index, "source_path", sourcePath)
705
+ setMeta(index, "source_data_version", String(state.dataVersion))
706
+ setMeta(index, "source_mtime_ms", String(state.mtimeMs))
707
+ index.exec("COMMIT")
708
+ } catch (err) {
709
+ index.exec("ROLLBACK")
710
+ throw err
711
+ } finally {
712
+ debug.timeEnd("fts:rebuild")
713
+ }
714
+ }
715
+
716
+ function ftsQuery(query: string) {
717
+ const tokens = query.trim().split(/\s+/)
718
+ .map((token) => token.replace(/["*^:()]/g, " ").trim())
719
+ .filter(Boolean)
720
+ if (!tokens.length) return ""
721
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"*`).join(" AND ")
722
+ }
723
+
724
+ function getMeta(db: Database, key: string) {
725
+ return db.query<{ value: string }, [string]>("SELECT value FROM index_meta WHERE key = ?").get(key)?.value
726
+ }
727
+
728
+ function setMeta(db: Database, key: string, value: string) {
729
+ db.query<unknown, [string, string]>(`
730
+ INSERT INTO index_meta(key, value) VALUES (?, ?)
731
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
732
+ `).run(key, value)
461
733
  }
462
734
 
735
+ const tableCache = new Map<string, boolean>()
463
736
  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))
737
+ if (tableCache.has(name)) return tableCache.get(name)!
738
+ const exists = Boolean(db.query<{ name: string }, [string]>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name))
739
+ tableCache.set(name, exists)
740
+ return exists
465
741
  }
466
742
 
467
743
  function defaultDataDir() {
package/telescope.tsx CHANGED
@@ -6,7 +6,9 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "so
6
6
  import { ConversationPreview, PreviewHeader } from "./components/preview.tsx"
7
7
  import { EmptyState, ResultRow, SkeletonRow } from "./components/result-list.tsx"
8
8
  import {
9
- loadConversationWindow,
9
+ loadConversationAfter,
10
+ loadConversationAround,
11
+ loadConversationBefore,
10
12
  recentSessionMessages,
11
13
  resolveDatabasePath,
12
14
  searchSessionMessages,
@@ -30,12 +32,18 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
30
32
  const [loading, setLoading] = createSignal(true)
31
33
  const [hasMore, setHasMore] = createSignal(true)
32
34
  const [loadingMore, setLoadingMore] = createSignal(false)
33
- const BATCH_SIZE = 25
34
- const RECENT_BATCH_SIZE = 15
35
- const INITIAL_PREVIEW_BEFORE = 3
36
- const INITIAL_PREVIEW_AFTER = 6
37
- const PREVIEW_EXPAND_STEP = 5
38
- const [previewRange, setPreviewRange] = createSignal({ before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
35
+ const [prefetchingResults, setPrefetchingResults] = createSignal(false)
36
+ const [resultPageInfo, setResultPageInfo] = createSignal({ loadedUntil: 0, hasMore: true, pageSize: 0, lastOffset: 0, lastAdded: 0 })
37
+ const [hasMorePreviewBefore, setHasMorePreviewBefore] = createSignal(false)
38
+ const [hasMorePreviewAfter, setHasMorePreviewAfter] = createSignal(false)
39
+ const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
40
+ const MIN_SEARCH_BATCH_SIZE = 25
41
+ const MIN_RECENT_BATCH_SIZE = 15
42
+ const RESULT_OVERSCAN_MULTIPLIER = 2
43
+ const RESULT_PREFETCH_VIEWPORTS = 3
44
+ const INITIAL_PREVIEW_BEFORE = 20
45
+ const INITIAL_PREVIEW_AFTER = 30
46
+ const PREVIEW_PAGE_SIZE = 20
39
47
  let input: InputRenderable | undefined
40
48
  let resultScroll: ScrollBoxRenderable | undefined
41
49
  let previewScroll: ScrollBoxRenderable | undefined
@@ -49,6 +57,47 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
49
57
  const verticalOffset = createMemo(() => Math.floor(dimensions().height / 4 - height() / 2) - 2)
50
58
  const dbPath = createMemo(() => resolveDatabasePath())
51
59
  const directory = props.api.state.path.directory
60
+ let advanceSelectionAfterLoad = false
61
+ let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
62
+ onCleanup(() => {
63
+ if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
64
+ })
65
+
66
+ const resultRowHeight = createMemo(() => leftWidth() >= 48 ? 3 : 4)
67
+ const visibleResultRows = () => {
68
+ const viewportHeight = resultScroll?.height || Math.max(8, height() - 7)
69
+ return Math.max(5, Math.floor(viewportHeight / resultRowHeight()))
70
+ }
71
+ const searchBatchSize = () => Math.max(MIN_SEARCH_BATCH_SIZE, visibleResultRows() * 2)
72
+ const recentBatchSize = () => Math.max(MIN_RECENT_BATCH_SIZE, visibleResultRows() * 2)
73
+ const resultPrefetchThreshold = () => Math.max(visibleResultRows() * RESULT_PREFETCH_VIEWPORTS, searchBatchSize())
74
+ const resultRenderWindow = createMemo(() => {
75
+ const total = results().length
76
+ const visible = visibleResultRows()
77
+ const overscan = visible * RESULT_OVERSCAN_MULTIPLIER
78
+ const index = selected()
79
+ const start = Math.max(0, index - overscan)
80
+ const end = Math.min(total, index + visible + overscan)
81
+ return { start, end, items: results().slice(start, end) }
82
+ })
83
+
84
+ let lastResultRenderWindow = ""
85
+ createEffect(() => {
86
+ const window = resultRenderWindow()
87
+ const key = `${window.start}:${window.end}:${results().length}`
88
+ if (key === lastResultRenderWindow) return
89
+ lastResultRenderWindow = key
90
+ debug.log("results:render-window", {
91
+ selected: selected(),
92
+ totalLoaded: results().length,
93
+ start: window.start,
94
+ end: window.end,
95
+ rendered: window.items.length,
96
+ visibleRows: visibleResultRows(),
97
+ overscanRows: visibleResultRows() * RESULT_OVERSCAN_MULTIPLIER,
98
+ pageInfo: resultPageInfo(),
99
+ })
100
+ })
52
101
 
53
102
  createEffect(() => {
54
103
  const q = query().trim()
@@ -59,15 +108,18 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
59
108
 
60
109
  if (!q) {
61
110
  setLoading(true)
111
+ const limit = recentBatchSize()
62
112
  const timer = setTimeout(() => {
63
113
  debug.time("query:recent")
64
114
  try {
65
- const batch = recentSessionMessages({ limit: RECENT_BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
115
+ const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir })
66
116
  setResults(batch)
67
- setHasMore(batch.length >= RECENT_BATCH_SIZE)
117
+ setHasMore(batch.length >= limit)
118
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
68
119
  setSelected(0)
69
120
  } catch (err) {
70
121
  setResults([])
122
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
71
123
  setError(err instanceof Error ? err.message : String(err))
72
124
  }
73
125
  debug.timeEnd("query:recent")
@@ -79,15 +131,18 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
79
131
 
80
132
  setLoading(true)
81
133
  setBusy(true)
134
+ const limit = searchBatchSize()
82
135
  const timer = setTimeout(() => {
83
136
  debug.time("query:search")
84
137
  try {
85
- const batch = searchSessionMessages(q, { limit: BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
138
+ const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir })
86
139
  setResults(batch)
87
- setHasMore(batch.length >= BATCH_SIZE)
140
+ setHasMore(batch.length >= limit)
141
+ setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
88
142
  setSelected(0)
89
143
  } catch (err) {
90
144
  setResults([])
145
+ setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
91
146
  setError(err instanceof Error ? err.message : String(err))
92
147
  } finally {
93
148
  debug.timeEnd("query:search")
@@ -98,15 +153,76 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
98
153
  onCleanup(() => clearTimeout(timer))
99
154
  })
100
155
 
156
+ const loadMoreResults = (advance = false) => {
157
+ if (advance) advanceSelectionAfterLoad = true
158
+ if (!hasMore()) {
159
+ advanceSelectionAfterLoad = false
160
+ return
161
+ }
162
+ if (loadingMore() || prefetchingResults() || busy() || loading()) return
163
+
164
+ const total = results().length
165
+ const q = query().trim()
166
+ const db = dbPath()
167
+ const dir = directory
168
+ const limit = q ? searchBatchSize() : recentBatchSize()
169
+
170
+ advance ? setLoadingMore(true) : setPrefetchingResults(true)
171
+ debug.time("query:load-more")
172
+ try {
173
+ const batch = q
174
+ ? searchSessionMessages(q, { limit, offset: total, dbPath: db, directory: dir })
175
+ : recentSessionMessages({ limit, offset: total, dbPath: db, directory: dir })
176
+ const nextHasMore = batch.length >= limit
177
+ const nextLoadedUntil = total + batch.length
178
+ debug.log("results:prefetch", { offset: total, limit, added: batch.length, totalLoaded: nextLoadedUntil, hasMore: nextHasMore, advance })
179
+ setResults((prev) => [...prev, ...batch])
180
+ setResultPageInfo({ loadedUntil: nextLoadedUntil, hasMore: nextHasMore, pageSize: limit, lastOffset: total, lastAdded: batch.length })
181
+ if (!nextHasMore) setHasMore(false)
182
+ if (advanceSelectionAfterLoad) {
183
+ debug.log("results:advance-after-load", { from: selected(), total, added: batch.length })
184
+ advanceSelectionAfterLoad = false
185
+ if (batch.length > 0) setSelected(total)
186
+ }
187
+ } catch (err) {
188
+ debug.log("results:load-more:error", err instanceof Error ? err.message : String(err))
189
+ advanceSelectionAfterLoad = false
190
+ } finally {
191
+ debug.timeEnd("query:load-more")
192
+ advance ? setLoadingMore(false) : setPrefetchingResults(false)
193
+ }
194
+ }
195
+
196
+ const scheduleResultPrefetch = (advance = false) => {
197
+ if (advance) advanceSelectionAfterLoad = true
198
+ if (resultPrefetchTimer || loadingMore() || prefetchingResults()) return
199
+ debug.log("results:prefetch-scheduled", { advance, pendingAdvance: advanceSelectionAfterLoad, pageInfo: resultPageInfo() })
200
+ resultPrefetchTimer = setTimeout(() => {
201
+ resultPrefetchTimer = undefined
202
+ loadMoreResults(advanceSelectionAfterLoad)
203
+ }, 1)
204
+ }
205
+
101
206
  const move = (delta: number) => {
102
207
  if (results().length === 0) return
103
- debug.time("nav:total")
104
- setSelected((index) => (index + delta + results().length) % results().length)
208
+ setSelected((index) => {
209
+ const next = index + delta
210
+ let finalIndex = next
211
+ if (next < 0) finalIndex = results().length - 1
212
+ else if (next >= results().length) {
213
+ if (hasMore()) scheduleResultPrefetch(true)
214
+ finalIndex = results().length - 1
215
+ }
216
+
217
+ if (finalIndex !== index) debug.time("nav:total")
218
+ return finalIndex
219
+ })
105
220
  }
106
221
 
107
222
  createEffect(() => {
108
223
  const index = selected()
109
- const row = resultScroll?.getChildren()[index]
224
+ const window = resultRenderWindow()
225
+ const row = resultScroll?.getChildren()[index - window.start]
110
226
  if (!resultScroll || !row) return
111
227
  const y = row.y - resultScroll.y
112
228
  if (y < 0) {
@@ -121,50 +237,107 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
121
237
  createEffect(() => {
122
238
  const index = selected()
123
239
  const total = results().length
124
- if (index < total - 3) return
125
- if (!hasMore() || loadingMore() || busy() || loading()) return
240
+ const threshold = resultPrefetchThreshold()
241
+ if (index < total - threshold) return
242
+ if (!hasMore() || loadingMore() || prefetchingResults() || busy() || loading()) return
126
243
 
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)
244
+ const timer = setTimeout(() => scheduleResultPrefetch(false), 100)
147
245
  onCleanup(() => clearTimeout(timer))
148
246
  })
149
247
 
248
+ const previewContentHeight = () => {
249
+ const children = previewScroll?.getChildren()
250
+ const lastChild = children?.[children.length - 1] as { y: number; height: number } | undefined
251
+ return lastChild ? lastChild.y + lastChild.height : 0
252
+ }
253
+
254
+ const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true) => {
255
+ const item = selectedResult()
256
+ const first = previewParts()[0]
257
+ if (!item || !first || loadingPreviewMore()) return
258
+ setLoadingPreviewMore(true)
259
+ debug.time("preview:load-before")
260
+ try {
261
+ const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
262
+ debug.log("preview:load-before", {
263
+ item: item.id,
264
+ added: page.parts.length,
265
+ hasMoreBefore: page.hasMoreBefore,
266
+ preserveScroll,
267
+ first: page.parts[0]?.id,
268
+ last: page.parts.at(-1)?.id,
269
+ })
270
+ if (page.parts.length > 0) {
271
+ setPreviewParts((prev) => [...page.parts, ...prev])
272
+ if (preserveScroll) {
273
+ setTimeout(() => {
274
+ const delta = previewContentHeight() - previousContentHeight
275
+ if (delta > 0) previewScroll?.scrollBy(delta)
276
+ }, 1)
277
+ }
278
+ }
279
+ setHasMorePreviewBefore(page.hasMoreBefore)
280
+ } catch (err) {
281
+ debug.log("preview:load-before:error", err instanceof Error ? err.message : String(err))
282
+ } finally {
283
+ debug.timeEnd("preview:load-before")
284
+ setLoadingPreviewMore(false)
285
+ }
286
+ }
287
+
288
+ const loadPreviewAfter = () => {
289
+ const item = selectedResult()
290
+ const last = previewParts().at(-1)
291
+ if (!item || !last || loadingPreviewMore()) return
292
+ setLoadingPreviewMore(true)
293
+ debug.time("preview:load-after")
294
+ try {
295
+ const page = loadConversationAfter(item, { id: last.id, timeCreated: last.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
296
+ debug.log("preview:load-after", {
297
+ item: item.id,
298
+ added: page.parts.length,
299
+ hasMoreAfter: page.hasMoreAfter,
300
+ first: page.parts[0]?.id,
301
+ last: page.parts.at(-1)?.id,
302
+ })
303
+ if (page.parts.length > 0) setPreviewParts((prev) => [...prev, ...page.parts])
304
+ setHasMorePreviewAfter(page.hasMoreAfter)
305
+ } catch (err) {
306
+ debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
307
+ } finally {
308
+ debug.timeEnd("preview:load-after")
309
+ setLoadingPreviewMore(false)
310
+ }
311
+ }
312
+
150
313
  let lastPreviewItemId = ""
151
314
  createEffect(() => {
152
315
  const item = selectedResult()
153
- const range = previewRange()
154
316
  if (!item) {
155
317
  setPreviewParts([])
318
+ setHasMorePreviewBefore(false)
319
+ setHasMorePreviewAfter(false)
156
320
  return
157
321
  }
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
163
- }
322
+ if (item.id === lastPreviewItemId) return
323
+ lastPreviewItemId = item.id
324
+ debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
164
325
  const db = dbPath()
165
326
  debug.time("preview:load")
166
327
  try {
167
- setPreviewParts(loadConversationWindow(item, { before: range.before, after: range.after, dbPath: db }))
328
+ const page = loadConversationAround(item, { before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER, dbPath: db })
329
+ debug.log("preview:init", {
330
+ item: item.id,
331
+ session: item.sessionID,
332
+ parts: page.parts.length,
333
+ hasMoreBefore: page.hasMoreBefore,
334
+ hasMoreAfter: page.hasMoreAfter,
335
+ first: page.parts[0]?.id,
336
+ last: page.parts.at(-1)?.id,
337
+ })
338
+ setPreviewParts(page.parts)
339
+ setHasMorePreviewBefore(page.hasMoreBefore)
340
+ setHasMorePreviewAfter(page.hasMoreAfter)
168
341
  } catch {}
169
342
  debug.timeEnd("nav:total")
170
343
  debug.timeEnd("preview:load")
@@ -174,24 +347,41 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
174
347
  const item = selectedResult()
175
348
  if (!item) return
176
349
  const interval = setInterval(() => {
350
+ if (loadingPreviewMore()) return
177
351
  const scroll = previewScroll
178
352
  const children = scroll?.getChildren()
179
353
  if (!scroll || !children || children.length === 0) return
180
- const range = previewRange()
181
354
  const lastChild = children[children.length - 1] as { y: number; height: number }
182
355
  const totalContentHeight = lastChild.y + lastChild.height
183
- const atTop = scroll.y <= 0 && range.before > 0
356
+ const atTop = scroll.y <= 0
357
+ const nearTop = scroll.y <= 2
184
358
  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 }))
359
+ if (nearTop || atBottom) {
360
+ debug.log("preview:scroll-edge", {
361
+ y: scroll.y,
362
+ height: scroll.height,
363
+ contentHeight: totalContentHeight,
364
+ atTop,
365
+ nearTop,
366
+ atBottom,
367
+ hasMoreBefore: hasMorePreviewBefore(),
368
+ hasMoreAfter: hasMorePreviewAfter(),
369
+ children: children.length,
370
+ })
371
+ }
372
+ if (nearTop && hasMorePreviewBefore()) loadPreviewBefore(totalContentHeight, !atTop)
373
+ if (atBottom && hasMorePreviewAfter()) loadPreviewAfter()
187
374
  }, 400)
188
375
  onCleanup(() => clearInterval(interval))
189
376
  })
190
377
 
378
+ let scrolledItem = ""
191
379
  createEffect(() => {
192
380
  const item = selectedResult()
193
381
  previewParts()
194
382
  if (!item) return
383
+ if (item.id === scrolledItem) return
384
+ scrolledItem = item.id
195
385
  const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
196
386
  onCleanup(() => clearTimeout(timer))
197
387
  })
@@ -324,18 +514,21 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
324
514
  >
325
515
  <Show when={!loading()}>
326
516
  <Show when={results().length > 0} fallback={<EmptyState query={query()} theme={theme()} />}>
327
- <For each={results()}>
328
- {(item, index) => (
329
- <ResultRow
330
- item={item}
331
- active={index() === selected()}
332
- width={leftWidth()}
333
- query={query()}
334
- theme={theme()}
335
- onMouseOver={() => setSelected(index())}
336
- onOpen={open}
337
- />
338
- )}
517
+ <For each={resultRenderWindow().items}>
518
+ {(item, index) => {
519
+ const absoluteIndex = () => resultRenderWindow().start + index()
520
+ return (
521
+ <ResultRow
522
+ item={item}
523
+ active={absoluteIndex() === selected()}
524
+ width={leftWidth()}
525
+ query={query()}
526
+ theme={theme()}
527
+ onMouseOver={() => setSelected(absoluteIndex())}
528
+ onOpen={open}
529
+ />
530
+ )
531
+ }}
339
532
  </For>
340
533
  <Show when={loadingMore()}>
341
534
  <SkeletonRow theme={theme()} />
package/ui/debug.ts CHANGED
@@ -1,6 +1,16 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs"
2
+ import path from "node:path"
3
+
1
4
  const enabled = process.env.OPENCODE_TELESCOPE_DEBUG === "1"
5
+ const logPath = process.env.OPENCODE_TELESCOPE_LOG
6
+ const consoleEnabled = !logPath || process.env.OPENCODE_TELESCOPE_CONSOLE === "1"
2
7
  const timers = new Map<string, number>()
3
8
 
9
+ if (enabled && logPath) {
10
+ mkdirSync(path.dirname(logPath), { recursive: true })
11
+ writeFileLog("session:start", { pid: process.pid })
12
+ }
13
+
4
14
  export const debug = {
5
15
  get enabled() {
6
16
  return enabled
@@ -14,12 +24,31 @@ export const debug = {
14
24
  if (!enabled) return
15
25
  const start = timers.get(label)
16
26
  if (start !== undefined) {
17
- console.log(`[telescope:time] ${label} ${(performance.now() - start).toFixed(2)}ms`)
27
+ writeLog("time", label, { ms: Number((performance.now() - start).toFixed(2)) })
18
28
  timers.delete(label)
19
29
  }
20
30
  },
21
31
 
22
32
  log(...args: unknown[]) {
23
- if (enabled) console.log("[telescope]", ...args)
33
+ if (enabled) writeLog("log", String(args[0] ?? "message"), args.length > 1 ? args.slice(1) : undefined)
24
34
  },
25
35
  }
36
+
37
+ function writeLog(type: "log" | "time", label: string, payload: unknown) {
38
+ if (logPath) writeFileLog(label, payload, type)
39
+ if (consoleEnabled) console.log(type === "time" ? `[telescope:time] ${label}` : "[telescope]", payload ?? "")
40
+ }
41
+
42
+ function writeFileLog(label: string, payload: unknown, type = "log") {
43
+ if (!logPath) return
44
+ appendFileSync(logPath, `${JSON.stringify({ ts: new Date().toISOString(), type, label, payload: safeJson(payload) })}\n`)
45
+ }
46
+
47
+ function safeJson(value: unknown) {
48
+ try {
49
+ JSON.stringify(value)
50
+ return value
51
+ } catch {
52
+ return String(value)
53
+ }
54
+ }