@bojackduy/opencode-telescope 0.1.12 → 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 +1 -1
- package/search.ts +125 -11
- package/telescope.tsx +243 -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",
|
package/search.ts
CHANGED
|
@@ -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
|
|
@@ -132,7 +143,7 @@ export function recentSessionMessages(options?: { limit?: number; offset?: numbe
|
|
|
132
143
|
)
|
|
133
144
|
}
|
|
134
145
|
|
|
135
|
-
export function
|
|
146
|
+
export function loadConversationAround(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }): ConversationPreviewPage {
|
|
136
147
|
debug.time("preview:query:total")
|
|
137
148
|
const db = getDb(options?.dbPath)
|
|
138
149
|
const before = options?.before ?? 3
|
|
@@ -144,12 +155,19 @@ export function loadConversationWindow(result: SearchResult, options?: { before?
|
|
|
144
155
|
).get(result.id)
|
|
145
156
|
debug.timeEnd("preview:query:hit")
|
|
146
157
|
if (!hit) {
|
|
158
|
+
debug.log("preview:window", {
|
|
159
|
+
item: result.id,
|
|
160
|
+
session: result.sessionID,
|
|
161
|
+
before,
|
|
162
|
+
after,
|
|
163
|
+
hit: false,
|
|
164
|
+
})
|
|
147
165
|
debug.timeEnd("preview:query:total")
|
|
148
|
-
return []
|
|
166
|
+
return { parts: [], hasMoreBefore: false, hasMoreAfter: false }
|
|
149
167
|
}
|
|
150
168
|
|
|
151
|
-
const fetchBefore = Math.max(before * 4, 30)
|
|
152
|
-
const fetchAfter = Math.max(after * 4, 50)
|
|
169
|
+
const fetchBefore = Math.max(before * 4, before + 1, 30)
|
|
170
|
+
const fetchAfter = Math.max((after + 1) * 4, after + 2, 50)
|
|
153
171
|
|
|
154
172
|
debug.time("preview:query:before")
|
|
155
173
|
const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
|
|
@@ -182,17 +200,108 @@ export function loadConversationWindow(result: SearchResult, options?: { before?
|
|
|
182
200
|
debug.timeEnd("preview:query:after")
|
|
183
201
|
|
|
184
202
|
debug.time("preview:query:parse")
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
(row.type === "text" || row.type === "reasoning" || row.type === "tool")
|
|
188
|
-
|
|
203
|
+
const validBefore = beforeRows.filter(isPreviewRow)
|
|
204
|
+
const validAfter = afterRows.filter(isPreviewRow)
|
|
189
205
|
const parts = [
|
|
190
|
-
...
|
|
191
|
-
...
|
|
206
|
+
...validBefore.slice(0, before).reverse(),
|
|
207
|
+
...validAfter.slice(0, after + 1),
|
|
192
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,
|
|
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
|
+
})
|
|
193
230
|
debug.timeEnd("preview:query:parse")
|
|
194
231
|
debug.timeEnd("preview:query:total")
|
|
195
|
-
return
|
|
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
|
|
196
305
|
}
|
|
197
306
|
|
|
198
307
|
export function rowToSearchResult(row: Row, query: string): SearchResult | undefined {
|
|
@@ -399,6 +508,11 @@ function parseConversationPart(row: ConversationRow, target: boolean): Conversat
|
|
|
399
508
|
}
|
|
400
509
|
}
|
|
401
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
|
+
|
|
402
516
|
function parsePartData(data: string) {
|
|
403
517
|
try {
|
|
404
518
|
const value = JSON.parse(data) as unknown
|
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,14 +153,67 @@ 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
208
|
setSelected((index) => {
|
|
104
209
|
const next = index + delta
|
|
105
210
|
let finalIndex = next
|
|
106
211
|
if (next < 0) finalIndex = results().length - 1
|
|
107
|
-
else if (next >= results().length)
|
|
108
|
-
|
|
212
|
+
else if (next >= results().length) {
|
|
213
|
+
if (hasMore()) scheduleResultPrefetch(true)
|
|
214
|
+
finalIndex = results().length - 1
|
|
215
|
+
}
|
|
216
|
+
|
|
109
217
|
if (finalIndex !== index) debug.time("nav:total")
|
|
110
218
|
return finalIndex
|
|
111
219
|
})
|
|
@@ -113,7 +221,8 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
113
221
|
|
|
114
222
|
createEffect(() => {
|
|
115
223
|
const index = selected()
|
|
116
|
-
const
|
|
224
|
+
const window = resultRenderWindow()
|
|
225
|
+
const row = resultScroll?.getChildren()[index - window.start]
|
|
117
226
|
if (!resultScroll || !row) return
|
|
118
227
|
const y = row.y - resultScroll.y
|
|
119
228
|
if (y < 0) {
|
|
@@ -128,50 +237,107 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
128
237
|
createEffect(() => {
|
|
129
238
|
const index = selected()
|
|
130
239
|
const total = results().length
|
|
131
|
-
|
|
132
|
-
if (
|
|
240
|
+
const threshold = resultPrefetchThreshold()
|
|
241
|
+
if (index < total - threshold) return
|
|
242
|
+
if (!hasMore() || loadingMore() || prefetchingResults() || busy() || loading()) return
|
|
133
243
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
244
|
+
const timer = setTimeout(() => scheduleResultPrefetch(false), 100)
|
|
245
|
+
onCleanup(() => clearTimeout(timer))
|
|
246
|
+
})
|
|
137
247
|
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
}
|
|
152
278
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
}
|
|
156
312
|
|
|
157
313
|
let lastPreviewItemId = ""
|
|
158
314
|
createEffect(() => {
|
|
159
315
|
const item = selectedResult()
|
|
160
|
-
const range = previewRange()
|
|
161
316
|
if (!item) {
|
|
162
317
|
setPreviewParts([])
|
|
318
|
+
setHasMorePreviewBefore(false)
|
|
319
|
+
setHasMorePreviewAfter(false)
|
|
163
320
|
return
|
|
164
321
|
}
|
|
165
|
-
if (item.id
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
setPreviewRange({ before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
|
|
169
|
-
return
|
|
170
|
-
}
|
|
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))
|
|
171
325
|
const db = dbPath()
|
|
172
326
|
debug.time("preview:load")
|
|
173
327
|
try {
|
|
174
|
-
|
|
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)
|
|
175
341
|
} catch {}
|
|
176
342
|
debug.timeEnd("nav:total")
|
|
177
343
|
debug.timeEnd("preview:load")
|
|
@@ -181,16 +347,30 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
181
347
|
const item = selectedResult()
|
|
182
348
|
if (!item) return
|
|
183
349
|
const interval = setInterval(() => {
|
|
350
|
+
if (loadingPreviewMore()) return
|
|
184
351
|
const scroll = previewScroll
|
|
185
352
|
const children = scroll?.getChildren()
|
|
186
353
|
if (!scroll || !children || children.length === 0) return
|
|
187
|
-
const range = previewRange()
|
|
188
354
|
const lastChild = children[children.length - 1] as { y: number; height: number }
|
|
189
355
|
const totalContentHeight = lastChild.y + lastChild.height
|
|
190
|
-
const atTop = scroll.y <= 0
|
|
356
|
+
const atTop = scroll.y <= 0
|
|
357
|
+
const nearTop = scroll.y <= 2
|
|
191
358
|
const atBottom = scroll.y + scroll.height >= totalContentHeight - 1
|
|
192
|
-
if (
|
|
193
|
-
|
|
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()
|
|
194
374
|
}, 400)
|
|
195
375
|
onCleanup(() => clearInterval(interval))
|
|
196
376
|
})
|
|
@@ -334,18 +514,21 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
334
514
|
>
|
|
335
515
|
<Show when={!loading()}>
|
|
336
516
|
<Show when={results().length > 0} fallback={<EmptyState query={query()} theme={theme()} />}>
|
|
337
|
-
<For each={
|
|
338
|
-
{(item, index) =>
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
+
}}
|
|
349
532
|
</For>
|
|
350
533
|
<Show when={loadingMore()}>
|
|
351
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
|
+
}
|