@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 +7 -7
- package/search.ts +370 -94
- package/telescope.tsx +253 -60
- package/ui/debug.ts +31 -2
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.
|
|
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.
|
|
67
|
-
"@opentui/core": "^0.1
|
|
68
|
-
"@opentui/solid": "^0.1
|
|
69
|
-
"@types/bun": "^1.3.
|
|
70
|
-
"solid-js": "^1.9.
|
|
71
|
-
"typescript": "^
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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 =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
146
|
+
export function loadConversationAround(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }): ConversationPreviewPage {
|
|
128
147
|
debug.time("preview:query:total")
|
|
129
|
-
const db =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
}
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
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
|
|
115
|
+
const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir })
|
|
66
116
|
setResults(batch)
|
|
67
|
-
setHasMore(batch.length >=
|
|
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
|
|
138
|
+
const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir })
|
|
86
139
|
setResults(batch)
|
|
87
|
-
setHasMore(batch.length >=
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
125
|
-
if (
|
|
240
|
+
const threshold = resultPrefetchThreshold()
|
|
241
|
+
if (index < total - threshold) return
|
|
242
|
+
if (!hasMore() || loadingMore() || prefetchingResults() || busy() || loading()) return
|
|
126
243
|
|
|
127
|
-
const
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
356
|
+
const atTop = scroll.y <= 0
|
|
357
|
+
const nearTop = scroll.y <= 2
|
|
184
358
|
const atBottom = scroll.y + scroll.height >= totalContentHeight - 1
|
|
185
|
-
if (
|
|
186
|
-
|
|
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={
|
|
328
|
-
{(item, index) =>
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
+
}
|