@bojackduy/opencode-telescope 0.1.21 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/package.json +9 -5
- package/telescope.tsx +98 -68
- package/ui/render-target.ts +6 -4
package/README.md
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
# opencode-telescope
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode TUI plugin for fuzzy-searching local conversation history, session transcripts, and past AI coding chats.
|
|
4
|
+
|
|
5
|
+
Install the npm package `@bojackduy/opencode-telescope` to grep through OpenCode chat history, find old code snippets, and jump back to any session instantly.
|
|
4
6
|
|
|
5
7
|
> Inspired by [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) — a fuzzy finder for your conversation history.
|
|
6
8
|
|
|
9
|
+
## Links
|
|
10
|
+
|
|
11
|
+
- Documentation: https://bojackduy.github.io/opencode-telescope/
|
|
12
|
+
- npm: https://www.npmjs.com/package/@bojackduy/opencode-telescope
|
|
13
|
+
- GitHub: https://github.com/bojackduy/opencode-telescope
|
|
14
|
+
- Issues: https://github.com/bojackduy/opencode-telescope/issues
|
|
15
|
+
|
|
7
16
|

|
|
8
17
|
|
|
9
18
|
## Use cases
|
|
@@ -19,9 +28,12 @@ Fuzzy search across all your OpenCode conversations — grep through session and
|
|
|
19
28
|
- **Live preview** — preview the matched conversation result before opening
|
|
20
29
|
- **Find & jump** — select any result and jump straight to that session
|
|
21
30
|
- **Neovim Telescope-style UX** — familiar `<leader>f` keybind and `/telescope` command
|
|
31
|
+
- **Local-first search** — reads your OpenCode session database without sending chat history to a remote service
|
|
22
32
|
|
|
23
33
|
## Installation
|
|
24
34
|
|
|
35
|
+
Install from npm as `@bojackduy/opencode-telescope`.
|
|
36
|
+
|
|
25
37
|
Add the plugin to your `tui.json`:
|
|
26
38
|
|
|
27
39
|
```jsonc
|
|
@@ -88,4 +100,8 @@ Key strings support simple names like `j`, `k`, `down`, `up`, `enter`, `return`,
|
|
|
88
100
|
|
|
89
101
|
Reads the OpenCode local SQLite session database in read-only mode, parses conversations into searchable text, and opens the selected session through the existing TUI route.
|
|
90
102
|
|
|
103
|
+
## Keywords
|
|
104
|
+
|
|
105
|
+
OpenCode plugin, OpenCode TUI, fuzzy finder, conversation search, chat history search, AI coding session search, LLM history, local session search, Telescope-style picker.
|
|
106
|
+
|
|
91
107
|

|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-telescope",
|
|
4
|
-
"version": "0.1.
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "0.1.22",
|
|
5
|
+
"description": "OpenCode TUI plugin for fuzzy-searching local conversation history, session transcripts, and past AI coding chats",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/
|
|
10
|
+
"url": "git+https://github.com/bojackduy/opencode-telescope.git"
|
|
11
11
|
},
|
|
12
|
-
"homepage": "https://github.
|
|
12
|
+
"homepage": "https://bojackduy.github.io/opencode-telescope/",
|
|
13
13
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/
|
|
14
|
+
"url": "https://github.com/bojackduy/opencode-telescope/issues"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"opencode",
|
|
@@ -21,11 +21,15 @@
|
|
|
21
21
|
"search",
|
|
22
22
|
"session",
|
|
23
23
|
"conversation",
|
|
24
|
+
"conversation-search",
|
|
24
25
|
"history",
|
|
26
|
+
"ai-chat-history",
|
|
27
|
+
"llm-history",
|
|
25
28
|
"fuzzy",
|
|
26
29
|
"fuzzy-finder",
|
|
27
30
|
"grep",
|
|
28
31
|
"finder",
|
|
32
|
+
"opencode-tui",
|
|
29
33
|
"neovim",
|
|
30
34
|
"chat-history"
|
|
31
35
|
],
|
package/telescope.tsx
CHANGED
|
@@ -93,6 +93,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
93
93
|
let resultNavigationStarted = false
|
|
94
94
|
let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
|
|
95
95
|
let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
|
|
96
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
97
|
+
let lastFiredQuery = ""
|
|
98
|
+
const SEARCH_DEBOUNCE_MS = 200
|
|
96
99
|
type PreviewBeforeIntent = "passive-prefetch" | "explicit-scroll"
|
|
97
100
|
type PendingPreviewBefore = {
|
|
98
101
|
id: number
|
|
@@ -193,6 +196,51 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
193
196
|
})
|
|
194
197
|
})
|
|
195
198
|
|
|
199
|
+
const executeSearch = (q: string, role: SearchRole | undefined, db: string, dir: string) => {
|
|
200
|
+
lastFiredQuery = q
|
|
201
|
+
const limit = q ? Math.min(searchBatchSize(), 80) : recentBatchSize()
|
|
202
|
+
setLoading(true)
|
|
203
|
+
if (q) setBusy(true)
|
|
204
|
+
debug.time("query:search")
|
|
205
|
+
try {
|
|
206
|
+
debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q || "(all recent)" })
|
|
207
|
+
const batch = q
|
|
208
|
+
? searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
|
|
209
|
+
: recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
|
|
210
|
+
debug.log("bootstrap:search:done", { rows: batch.length, limit })
|
|
211
|
+
solidBatch(() => {
|
|
212
|
+
setResults(batch)
|
|
213
|
+
setResultBaseOffset(0)
|
|
214
|
+
setNextResultOffset(batch.length)
|
|
215
|
+
setHasMore(batch.length >= limit)
|
|
216
|
+
setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
|
|
217
|
+
setSelected(0)
|
|
218
|
+
})
|
|
219
|
+
} catch (err) {
|
|
220
|
+
debug.log("bootstrap:search:error", err instanceof Error ? err.message : String(err))
|
|
221
|
+
solidBatch(() => {
|
|
222
|
+
setResults([])
|
|
223
|
+
setResultBaseOffset(0)
|
|
224
|
+
setNextResultOffset(0)
|
|
225
|
+
setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
|
|
226
|
+
})
|
|
227
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
228
|
+
} finally {
|
|
229
|
+
debug.timeEnd("query:search")
|
|
230
|
+
setBusy(false)
|
|
231
|
+
setLoading(false)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const scheduleSearch = (q: string, role: SearchRole | undefined, db: string, dir: string) => {
|
|
236
|
+
if (searchTimer) clearTimeout(searchTimer)
|
|
237
|
+
if (q === lastFiredQuery && results().length > 0) return
|
|
238
|
+
searchTimer = setTimeout(() => {
|
|
239
|
+
searchTimer = undefined
|
|
240
|
+
executeSearch(q, role, db, dir)
|
|
241
|
+
}, q ? SEARCH_DEBOUNCE_MS : 1)
|
|
242
|
+
}
|
|
243
|
+
|
|
196
244
|
createEffect(() => {
|
|
197
245
|
const q = query().trim()
|
|
198
246
|
const role = ownerRole()
|
|
@@ -211,73 +259,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
211
259
|
const db = dbPath()
|
|
212
260
|
const dir = directory
|
|
213
261
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
debug.time("query:recent")
|
|
220
|
-
try {
|
|
221
|
-
const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
|
|
222
|
-
debug.log("bootstrap:recent:done", { rows: batch.length, limit })
|
|
223
|
-
solidBatch(() => {
|
|
224
|
-
setResults(batch)
|
|
225
|
-
setResultBaseOffset(0)
|
|
226
|
-
setNextResultOffset(batch.length)
|
|
227
|
-
setHasMore(batch.length >= limit)
|
|
228
|
-
setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
|
|
229
|
-
setSelected(0)
|
|
230
|
-
})
|
|
231
|
-
} catch (err) {
|
|
232
|
-
debug.log("bootstrap:recent:error", err instanceof Error ? err.message : String(err))
|
|
233
|
-
solidBatch(() => {
|
|
234
|
-
setResults([])
|
|
235
|
-
setResultBaseOffset(0)
|
|
236
|
-
setNextResultOffset(0)
|
|
237
|
-
setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
|
|
238
|
-
})
|
|
239
|
-
setError(err instanceof Error ? err.message : String(err))
|
|
240
|
-
}
|
|
241
|
-
debug.timeEnd("query:recent")
|
|
242
|
-
setLoading(false)
|
|
243
|
-
debug.log("component:interactive")
|
|
244
|
-
}, 1)
|
|
245
|
-
onCleanup(() => clearTimeout(timer))
|
|
246
|
-
return
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
setLoading(true)
|
|
250
|
-
setBusy(true)
|
|
251
|
-
const limit = searchBatchSize()
|
|
252
|
-
const timer = setTimeout(() => {
|
|
253
|
-
debug.time("query:search")
|
|
254
|
-
try {
|
|
255
|
-
debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q })
|
|
256
|
-
const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
|
|
257
|
-
debug.log("bootstrap:search:done", { rows: batch.length, limit })
|
|
258
|
-
solidBatch(() => {
|
|
259
|
-
setResults(batch)
|
|
260
|
-
setResultBaseOffset(0)
|
|
261
|
-
setNextResultOffset(batch.length)
|
|
262
|
-
setHasMore(batch.length >= limit)
|
|
263
|
-
setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
|
|
264
|
-
setSelected(0)
|
|
265
|
-
})
|
|
266
|
-
} catch (err) {
|
|
267
|
-
solidBatch(() => {
|
|
268
|
-
setResults([])
|
|
269
|
-
setResultBaseOffset(0)
|
|
270
|
-
setNextResultOffset(0)
|
|
271
|
-
setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
|
|
272
|
-
})
|
|
273
|
-
setError(err instanceof Error ? err.message : String(err))
|
|
274
|
-
} finally {
|
|
275
|
-
debug.timeEnd("query:search")
|
|
276
|
-
setBusy(false)
|
|
277
|
-
setLoading(false)
|
|
278
|
-
}
|
|
279
|
-
}, 180)
|
|
280
|
-
onCleanup(() => clearTimeout(timer))
|
|
262
|
+
scheduleSearch(q, role, db, dir)
|
|
263
|
+
onCleanup(() => {
|
|
264
|
+
if (searchTimer) clearTimeout(searchTimer)
|
|
265
|
+
searchTimer = undefined
|
|
266
|
+
})
|
|
281
267
|
})
|
|
282
268
|
|
|
283
269
|
const loadMoreResults = (advance = false) => {
|
|
@@ -778,6 +764,33 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
778
764
|
})
|
|
779
765
|
}
|
|
780
766
|
}
|
|
767
|
+
|
|
768
|
+
if (pending.intent === "passive-prefetch") {
|
|
769
|
+
const matchItem = selectedResult()
|
|
770
|
+
const scroll = previewScroll
|
|
771
|
+
if (matchItem && scroll) {
|
|
772
|
+
const layout = previewVirtualLayout()
|
|
773
|
+
const matchRow = layout.find((r) => r.part.id === matchItem.id)
|
|
774
|
+
if (matchRow) {
|
|
775
|
+
const viewportHeight = scroll.height
|
|
776
|
+
const desiredScrollTop = Math.max(0, matchRow.top - Math.floor(viewportHeight / 3))
|
|
777
|
+
const msSinceManual = Date.now() - lastPreviewScrollKeyMs
|
|
778
|
+
if (Math.abs(scroll.scrollTop - desiredScrollTop) >= 2 && msSinceManual > 700) {
|
|
779
|
+
const correctedWindow = previewWindowForLayout(layout, desiredScrollTop, viewportHeight)
|
|
780
|
+
setPreviewWindow(correctedWindow)
|
|
781
|
+
scroll.scrollTo(desiredScrollTop)
|
|
782
|
+
debug.log("preview:load-before:recenter", {
|
|
783
|
+
matchPartID: matchItem.id,
|
|
784
|
+
matchTop: matchRow.top,
|
|
785
|
+
scrollTop: scroll.scrollTop,
|
|
786
|
+
desiredScrollTop,
|
|
787
|
+
viewportHeight,
|
|
788
|
+
window: correctedWindow,
|
|
789
|
+
})
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
781
794
|
}, 1)
|
|
782
795
|
}
|
|
783
796
|
setHasMorePreviewBefore(page.hasMoreBefore)
|
|
@@ -971,7 +984,24 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
971
984
|
debug.log("preview:target-scroll:estimated", { targetID, targetTop: row.top, scrollTop: scroll.scrollTop, nextScrollTop: top, window: previewWindow() })
|
|
972
985
|
scroll.scrollTo(top)
|
|
973
986
|
updatePreviewWindow()
|
|
974
|
-
|
|
987
|
+
const scrollTopBeforeReal = scroll.scrollTop
|
|
988
|
+
setTimeout(() => {
|
|
989
|
+
scrollPreviewToTarget(scroll, targetID)
|
|
990
|
+
const now = Date.now()
|
|
991
|
+
const adjustmentMs = lastPreviewScrollKeyMs
|
|
992
|
+
if (adjustmentMs > 0 && now - adjustmentMs > 300) {
|
|
993
|
+
const targetTopReal = row.top
|
|
994
|
+
const expectedScrollTop = Math.max(0, targetTopReal - Math.max(1, Math.floor(scroll.height / 3)))
|
|
995
|
+
const drift = scroll.scrollTop - expectedScrollTop
|
|
996
|
+
if (Math.abs(drift) >= 2) {
|
|
997
|
+
const correctedScrollTop = scroll.scrollTop - drift
|
|
998
|
+
const correctedWindow = previewWindowForLayout(previewVirtualLayout(), correctedScrollTop, scroll.height)
|
|
999
|
+
setPreviewWindow(correctedWindow)
|
|
1000
|
+
scroll.scrollTo(correctedScrollTop)
|
|
1001
|
+
debug.log("preview:target-scroll:real-correct", { targetID, drift, scrollTopAfterReal: scroll.scrollTop, correctedScrollTop, window: correctedWindow })
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}, 1)
|
|
975
1005
|
}
|
|
976
1006
|
|
|
977
1007
|
let lastPreviewItemId = ""
|
package/ui/render-target.ts
CHANGED
|
@@ -31,18 +31,20 @@ export function scrollPreviewToTarget(scroll: ScrollBoxRenderable | undefined, t
|
|
|
31
31
|
})
|
|
32
32
|
return
|
|
33
33
|
}
|
|
34
|
-
const
|
|
34
|
+
const contentY = target.y + scroll.scrollTop - scroll.y
|
|
35
|
+
const desiredScrollTop = Math.max(0, contentY - Math.max(1, Math.floor(scroll.height / 3)))
|
|
35
36
|
debug.log("preview:target-scroll", {
|
|
36
37
|
targetID,
|
|
37
38
|
targetY: target.y,
|
|
38
39
|
scrollY: scroll.y,
|
|
39
40
|
scrollTop: scroll.scrollTop,
|
|
41
|
+
contentY,
|
|
42
|
+
desiredScrollTop,
|
|
40
43
|
scrollHeight: scroll.height,
|
|
41
44
|
contentHeight: scroll.scrollHeight,
|
|
42
|
-
delta,
|
|
43
45
|
})
|
|
44
|
-
scroll.
|
|
45
|
-
debug.log("preview:target-scroll:after", { targetID, scrollY: scroll.y, scrollTop: scroll.scrollTop })
|
|
46
|
+
scroll.scrollTo(desiredScrollTop)
|
|
47
|
+
debug.log("preview:target-scroll:after", { targetID, scrollY: scroll.y, scrollTop: scroll.scrollTop, desiredScrollTop })
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export function jumpToRenderedTarget(root: unknown, targetID: string) {
|