@bojackduy/opencode-telescope 0.1.21 → 0.1.23
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 +68 -2
- package/docs/index.html +298 -0
- package/docs/semantic-search.md +92 -0
- package/package.json +18 -5
- package/search.ts +430 -3
- package/telescope.tsx +111 -71
- package/ui/render-target.ts +6 -4
package/README.md
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
# opencode-telescope
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode TUI plugin for fuzzy and semantic search across local conversation history, session transcripts, and past AI coding chats.
|
|
4
|
+
|
|
5
|
+
Install the npm package `@bojackduy/opencode-telescope` to grep OpenCode chat history, find old code snippets, search by meaning with local embeddings, 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
|
|
10
19
|
|
|
11
20
|
- **"I know I discussed this somewhere"** — grep all your sessions by keyword
|
|
21
|
+
- **"What was that auth caching idea?"** — semantic search can find related chats even when the exact words differ
|
|
12
22
|
- **"Find that code snippet"** — search for code you saw in a past LLM response
|
|
13
23
|
- **"Revisit a decision"** — find the conversation where you chose approach X
|
|
14
24
|
- **"Session journal"** — browse your entire conversation history like a timeline
|
|
@@ -16,12 +26,17 @@ Fuzzy search across all your OpenCode conversations — grep through session and
|
|
|
16
26
|
## Features
|
|
17
27
|
|
|
18
28
|
- **Fuzzy grep** — search across all session messages by text
|
|
29
|
+
- **Semantic memory search** — optional local vector search with `llama-server` embeddings and `sqlite-vec`
|
|
30
|
+
- **Hybrid ranking** — blends keyword/FTS matches with semantic vector results when vector search is ready
|
|
19
31
|
- **Live preview** — preview the matched conversation result before opening
|
|
20
32
|
- **Find & jump** — select any result and jump straight to that session
|
|
21
33
|
- **Neovim Telescope-style UX** — familiar `<leader>f` keybind and `/telescope` command
|
|
34
|
+
- **Local-first search** — reads your OpenCode session database without sending chat history to a remote service
|
|
22
35
|
|
|
23
36
|
## Installation
|
|
24
37
|
|
|
38
|
+
Install from npm as `@bojackduy/opencode-telescope`.
|
|
39
|
+
|
|
25
40
|
Add the plugin to your `tui.json`:
|
|
26
41
|
|
|
27
42
|
```jsonc
|
|
@@ -47,6 +62,53 @@ Add the plugin to your `tui.json`:
|
|
|
47
62
|
| Open | Press `Enter` to jump to the selected session |
|
|
48
63
|
| Owner filter | Press `o` to cycle `all` / `you` / `assistant` |
|
|
49
64
|
|
|
65
|
+
## Semantic Search
|
|
66
|
+
|
|
67
|
+
Semantic search is optional. Keyword and fuzzy search work out of the box; when vector dependencies are available, Telescope builds a local sidecar vector index and uses hybrid ranking so meaning-based matches can appear even when the query does not share exact words with the original chat.
|
|
68
|
+
|
|
69
|
+
The semantic path stays local-first:
|
|
70
|
+
|
|
71
|
+
- OpenCode's SQLite database is opened read-only.
|
|
72
|
+
- Derived keyword and vector indexes are stored in Telescope's sidecar database.
|
|
73
|
+
- Embeddings are generated through your local `llama-server` endpoint.
|
|
74
|
+
- If embeddings or `sqlite-vec` are unavailable, Telescope falls back to keyword search.
|
|
75
|
+
|
|
76
|
+
Quick setup:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
mkdir -p "$HOME/.local/share/opencode-telescope/models"
|
|
80
|
+
|
|
81
|
+
huggingface-cli download \
|
|
82
|
+
nomic-ai/nomic-embed-text-v1.5-GGUF \
|
|
83
|
+
nomic-embed-text-v1.5.f16.gguf \
|
|
84
|
+
--local-dir "$HOME/.local/share/opencode-telescope/models" \
|
|
85
|
+
--local-dir-use-symlinks false
|
|
86
|
+
|
|
87
|
+
llama-server \
|
|
88
|
+
-m "$HOME/.local/share/opencode-telescope/models/nomic-embed-text-v1.5.f16.gguf" \
|
|
89
|
+
--embedding \
|
|
90
|
+
--pooling mean \
|
|
91
|
+
-c 8192 \
|
|
92
|
+
-ub 8192 \
|
|
93
|
+
--host 127.0.0.1 \
|
|
94
|
+
--port 8081
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then launch OpenCode normally. Telescope uses `http://127.0.0.1:8081` by default and will rebuild the vector sidecar from your local session history.
|
|
98
|
+
|
|
99
|
+
Useful environment variables:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
OPENCODE_TELESCOPE_EMBED_BASE_URL=http://127.0.0.1:8081
|
|
103
|
+
OPENCODE_TELESCOPE_EMBED_MODEL=nomic-embed-text-v1.5
|
|
104
|
+
OPENCODE_TELESCOPE_HYBRID_ALPHA=0.45
|
|
105
|
+
OPENCODE_TELESCOPE_DISABLE_VECTOR=1
|
|
106
|
+
OPENCODE_TELESCOPE_SQLITE_LIB=/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib
|
|
107
|
+
OPENCODE_TELESCOPE_SQLITE_VEC_EXT=/path/to/vec0
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
See the full tutorial in [`docs/semantic-search.md`](./docs/semantic-search.md).
|
|
111
|
+
|
|
50
112
|
## Configuration
|
|
51
113
|
|
|
52
114
|
Telescope reads optional plugin-specific config from:
|
|
@@ -86,6 +148,10 @@ Key strings support simple names like `j`, `k`, `down`, `up`, `enter`, `return`,
|
|
|
86
148
|
|
|
87
149
|
## How it works
|
|
88
150
|
|
|
89
|
-
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.
|
|
151
|
+
Reads the OpenCode local SQLite session database in read-only mode, parses conversations into searchable text, builds a Telescope-owned sidecar index, and opens the selected session through the existing TUI route. Optional semantic search adds local embeddings and `sqlite-vec` vector retrieval on top of the same sidecar.
|
|
152
|
+
|
|
153
|
+
## Keywords
|
|
154
|
+
|
|
155
|
+
OpenCode plugin, OpenCode TUI, fuzzy finder, semantic search, vector search, embeddings, sqlite-vec, llama-server, conversation search, chat history search, AI coding session search, LLM history, local session search, Telescope-style picker.
|
|
90
156
|
|
|
91
157
|

|
package/docs/index.html
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>opencode-telescope - Fuzzy and Semantic Search for OpenCode Chat History</title>
|
|
7
|
+
<meta
|
|
8
|
+
name="description"
|
|
9
|
+
content="opencode-telescope is an OpenCode TUI plugin for fuzzy and semantic search across local conversation history, session transcripts, AI coding chats, and old code snippets."
|
|
10
|
+
/>
|
|
11
|
+
<meta
|
|
12
|
+
name="keywords"
|
|
13
|
+
content="OpenCode, OpenCode plugin, OpenCode TUI, fuzzy search, semantic search, vector search, embeddings, sqlite-vec, conversation search, chat history search, AI coding session search, LLM history, telescope"
|
|
14
|
+
/>
|
|
15
|
+
<link rel="canonical" href="https://bojackduy.github.io/opencode-telescope/" />
|
|
16
|
+
<meta property="og:title" content="opencode-telescope" />
|
|
17
|
+
<meta
|
|
18
|
+
property="og:description"
|
|
19
|
+
content="Fuzzy and semantic search for local OpenCode conversations, session transcripts, and past AI coding chats from the TUI."
|
|
20
|
+
/>
|
|
21
|
+
<meta property="og:type" content="website" />
|
|
22
|
+
<meta property="og:url" content="https://bojackduy.github.io/opencode-telescope/" />
|
|
23
|
+
<meta
|
|
24
|
+
property="og:image"
|
|
25
|
+
content="https://raw.githubusercontent.com/bojackduy/opencode-telescope/main/assets/demo.png"
|
|
26
|
+
/>
|
|
27
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
28
|
+
<meta
|
|
29
|
+
name="twitter:image"
|
|
30
|
+
content="https://raw.githubusercontent.com/bojackduy/opencode-telescope/main/assets/demo.png"
|
|
31
|
+
/>
|
|
32
|
+
<style>
|
|
33
|
+
:root {
|
|
34
|
+
color-scheme: dark;
|
|
35
|
+
--bg: #090b10;
|
|
36
|
+
--panel: #111722;
|
|
37
|
+
--text: #eaf0ff;
|
|
38
|
+
--muted: #a8b3cf;
|
|
39
|
+
--accent: #8bd5ff;
|
|
40
|
+
--accent-2: #c6a0ff;
|
|
41
|
+
--border: #283247;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
* {
|
|
45
|
+
box-sizing: border-box;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
body {
|
|
49
|
+
margin: 0;
|
|
50
|
+
background:
|
|
51
|
+
radial-gradient(circle at top left, rgba(139, 213, 255, 0.18), transparent 30rem),
|
|
52
|
+
radial-gradient(circle at bottom right, rgba(198, 160, 255, 0.14), transparent 28rem),
|
|
53
|
+
var(--bg);
|
|
54
|
+
color: var(--text);
|
|
55
|
+
font-family:
|
|
56
|
+
ui-sans-serif,
|
|
57
|
+
system-ui,
|
|
58
|
+
-apple-system,
|
|
59
|
+
BlinkMacSystemFont,
|
|
60
|
+
"Segoe UI",
|
|
61
|
+
sans-serif;
|
|
62
|
+
line-height: 1.6;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
main {
|
|
66
|
+
width: min(72rem, calc(100% - 2rem));
|
|
67
|
+
margin: 0 auto;
|
|
68
|
+
padding: 4rem 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.hero {
|
|
72
|
+
display: grid;
|
|
73
|
+
grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr);
|
|
74
|
+
gap: 2rem;
|
|
75
|
+
align-items: center;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.eyebrow {
|
|
79
|
+
color: var(--accent);
|
|
80
|
+
font-weight: 700;
|
|
81
|
+
letter-spacing: 0.12em;
|
|
82
|
+
text-transform: uppercase;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
h1 {
|
|
86
|
+
margin: 0.5rem 0 1rem;
|
|
87
|
+
font-size: clamp(2.6rem, 8vw, 5.8rem);
|
|
88
|
+
line-height: 0.92;
|
|
89
|
+
letter-spacing: -0.07em;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
h2 {
|
|
93
|
+
margin-top: 0;
|
|
94
|
+
font-size: 1.35rem;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
p {
|
|
98
|
+
color: var(--muted);
|
|
99
|
+
font-size: 1.08rem;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
a {
|
|
103
|
+
color: var(--accent);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.actions {
|
|
107
|
+
display: flex;
|
|
108
|
+
flex-wrap: wrap;
|
|
109
|
+
gap: 0.75rem;
|
|
110
|
+
margin-top: 1.5rem;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.button {
|
|
114
|
+
border: 1px solid var(--border);
|
|
115
|
+
border-radius: 999px;
|
|
116
|
+
color: var(--text);
|
|
117
|
+
display: inline-flex;
|
|
118
|
+
font-weight: 700;
|
|
119
|
+
padding: 0.8rem 1rem;
|
|
120
|
+
text-decoration: none;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.button.primary {
|
|
124
|
+
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
125
|
+
color: #07101a;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.card,
|
|
129
|
+
pre {
|
|
130
|
+
background: rgba(17, 23, 34, 0.86);
|
|
131
|
+
border: 1px solid var(--border);
|
|
132
|
+
border-radius: 1.25rem;
|
|
133
|
+
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.28);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.card {
|
|
137
|
+
padding: 1.25rem;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.demo-card {
|
|
141
|
+
padding: 0.8rem;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.demo-card img {
|
|
145
|
+
border: 1px solid var(--border);
|
|
146
|
+
border-radius: 0.9rem;
|
|
147
|
+
display: block;
|
|
148
|
+
height: auto;
|
|
149
|
+
width: 100%;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.caption {
|
|
153
|
+
color: var(--muted);
|
|
154
|
+
font-size: 0.92rem;
|
|
155
|
+
margin: 0.8rem 0 0.2rem;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
pre {
|
|
159
|
+
color: #d9e7ff;
|
|
160
|
+
overflow-x: auto;
|
|
161
|
+
padding: 1rem;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
code {
|
|
165
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.grid {
|
|
169
|
+
display: grid;
|
|
170
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
171
|
+
gap: 1rem;
|
|
172
|
+
margin-top: 2rem;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.links {
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-wrap: wrap;
|
|
178
|
+
gap: 1rem;
|
|
179
|
+
margin-top: 2rem;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.showcase {
|
|
183
|
+
display: grid;
|
|
184
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
185
|
+
gap: 1rem;
|
|
186
|
+
margin-top: 2rem;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@media (max-width: 760px) {
|
|
190
|
+
main {
|
|
191
|
+
padding: 2.5rem 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.hero,
|
|
195
|
+
.grid,
|
|
196
|
+
.showcase {
|
|
197
|
+
grid-template-columns: 1fr;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
</style>
|
|
201
|
+
</head>
|
|
202
|
+
<body>
|
|
203
|
+
<main>
|
|
204
|
+
<section class="hero">
|
|
205
|
+
<div>
|
|
206
|
+
<div class="eyebrow">OpenCode TUI plugin</div>
|
|
207
|
+
<h1>Find the chat where it happened.</h1>
|
|
208
|
+
<p>
|
|
209
|
+
<strong>opencode-telescope</strong> adds a Telescope-style fuzzy finder
|
|
210
|
+
to OpenCode so you can search local conversation history by keyword
|
|
211
|
+
or meaning, recover old code snippets, and jump back to the exact chat.
|
|
212
|
+
</p>
|
|
213
|
+
<div class="actions">
|
|
214
|
+
<a class="button primary" href="https://www.npmjs.com/package/@bojackduy/opencode-telescope">Install from npm</a>
|
|
215
|
+
<a class="button" href="https://github.com/bojackduy/opencode-telescope">View on GitHub</a>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="card">
|
|
219
|
+
<h2>Package</h2>
|
|
220
|
+
<pre><code>@bojackduy/opencode-telescope</code></pre>
|
|
221
|
+
<p>
|
|
222
|
+
Search OpenCode sessions by keyword or semantic similarity, preview
|
|
223
|
+
matches, and jump back into the exact conversation without leaving the TUI.
|
|
224
|
+
</p>
|
|
225
|
+
</div>
|
|
226
|
+
</section>
|
|
227
|
+
|
|
228
|
+
<section class="showcase" aria-label="Demo screenshots">
|
|
229
|
+
<div class="card demo-card">
|
|
230
|
+
<img
|
|
231
|
+
src="https://raw.githubusercontent.com/bojackduy/opencode-telescope/main/assets/demo.png"
|
|
232
|
+
alt="opencode-telescope fuzzy search UI preview inside the OpenCode terminal"
|
|
233
|
+
loading="lazy"
|
|
234
|
+
/>
|
|
235
|
+
<p class="caption">Search across OpenCode sessions with a Telescope-style result list and preview pane.</p>
|
|
236
|
+
</div>
|
|
237
|
+
<div class="card demo-card">
|
|
238
|
+
<img
|
|
239
|
+
src="https://raw.githubusercontent.com/bojackduy/opencode-telescope/main/assets/demo.gif"
|
|
240
|
+
alt="Animated opencode-telescope demo showing chat history search and session navigation"
|
|
241
|
+
loading="lazy"
|
|
242
|
+
/>
|
|
243
|
+
<p class="caption">Filter conversation history, inspect matches, and jump back to the exact chat.</p>
|
|
244
|
+
</div>
|
|
245
|
+
</section>
|
|
246
|
+
|
|
247
|
+
<section class="grid" aria-label="Features">
|
|
248
|
+
<div class="card">
|
|
249
|
+
<h2>Fuzzy + semantic</h2>
|
|
250
|
+
<p>Grep exact words or use local embeddings to find conversations by meaning.</p>
|
|
251
|
+
</div>
|
|
252
|
+
<div class="card">
|
|
253
|
+
<h2>Hybrid ranking</h2>
|
|
254
|
+
<p>Blend SQLite FTS matches with vector results from a local sidecar index.</p>
|
|
255
|
+
</div>
|
|
256
|
+
<div class="card">
|
|
257
|
+
<h2>Local first</h2>
|
|
258
|
+
<p>Reads OpenCode's SQLite database in read-only mode and uses your local embedding server.</p>
|
|
259
|
+
</div>
|
|
260
|
+
</section>
|
|
261
|
+
|
|
262
|
+
<section style="margin-top: 2rem">
|
|
263
|
+
<h2>Semantic memory search</h2>
|
|
264
|
+
<p>
|
|
265
|
+
Run a local <code>llama-server</code> embedding endpoint and Telescope can build a
|
|
266
|
+
<code>sqlite-vec</code> sidecar index for meaning-based recall. If vector dependencies
|
|
267
|
+
are missing, keyword search keeps working.
|
|
268
|
+
</p>
|
|
269
|
+
<pre><code>OPENCODE_TELESCOPE_EMBED_BASE_URL=http://127.0.0.1:8081
|
|
270
|
+
OPENCODE_TELESCOPE_EMBED_MODEL=nomic-embed-text-v1.5
|
|
271
|
+
OPENCODE_TELESCOPE_HYBRID_ALPHA=0.45</code></pre>
|
|
272
|
+
<p>
|
|
273
|
+
<a href="https://github.com/bojackduy/opencode-telescope/blob/main/docs/semantic-search.md">Read the semantic search setup guide</a>
|
|
274
|
+
</p>
|
|
275
|
+
</section>
|
|
276
|
+
|
|
277
|
+
<section style="margin-top: 2rem">
|
|
278
|
+
<h2>Install</h2>
|
|
279
|
+
<pre><code>{
|
|
280
|
+
"plugin": ["@bojackduy/opencode-telescope"]
|
|
281
|
+
}</code></pre>
|
|
282
|
+
<p>
|
|
283
|
+
Open search with <code><leader>f</code> or <code>/telescope</code>, type
|
|
284
|
+
to filter conversation text or describe the idea you remember, then press
|
|
285
|
+
<code>Enter</code> to jump to the selected OpenCode session.
|
|
286
|
+
</p>
|
|
287
|
+
</section>
|
|
288
|
+
|
|
289
|
+
<nav class="links" aria-label="Project links">
|
|
290
|
+
<a href="https://github.com/bojackduy/opencode-telescope">GitHub repository</a>
|
|
291
|
+
<a href="https://www.npmjs.com/package/@bojackduy/opencode-telescope">npm package</a>
|
|
292
|
+
<a href="https://github.com/bojackduy/opencode-telescope/blob/main/docs/semantic-search.md">Semantic search guide</a>
|
|
293
|
+
<a href="https://github.com/bojackduy/opencode-telescope/issues">Issues</a>
|
|
294
|
+
<a href="https://bojackduy.github.io/opencode-telescope/sitemap.xml">Sitemap</a>
|
|
295
|
+
</nav>
|
|
296
|
+
</main>
|
|
297
|
+
</body>
|
|
298
|
+
</html>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Semantic Search Setup
|
|
2
|
+
|
|
3
|
+
opencode-telescope can search OpenCode chat history by meaning, not only by exact keywords. The default keyword/fuzzy search still works without extra setup. Semantic search turns on automatically when the local vector dependencies are ready.
|
|
4
|
+
|
|
5
|
+
## What It Adds
|
|
6
|
+
|
|
7
|
+
- Hybrid search across keyword FTS results and vector semantic matches.
|
|
8
|
+
- Vector-only results when a query is related by meaning but not exact wording.
|
|
9
|
+
- Local embeddings through a `llama-server` endpoint.
|
|
10
|
+
- A Telescope-owned sidecar index so OpenCode's source database stays read-only.
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- `@bojackduy/opencode-telescope` installed in OpenCode.
|
|
15
|
+
- A local `llama-server` with an embedding model.
|
|
16
|
+
- `sqlite-vec`, provided by the package dependency or by `OPENCODE_TELESCOPE_SQLITE_VEC_EXT`.
|
|
17
|
+
- On macOS, an extension-capable SQLite library. Telescope tries Homebrew SQLite automatically, or you can set `OPENCODE_TELESCOPE_SQLITE_LIB`.
|
|
18
|
+
|
|
19
|
+
## Recommended Model
|
|
20
|
+
|
|
21
|
+
Use `nomic-ai/nomic-embed-text-v1.5-GGUF` for local embedding search.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
mkdir -p "$HOME/.local/share/opencode-telescope/models"
|
|
25
|
+
|
|
26
|
+
huggingface-cli download \
|
|
27
|
+
nomic-ai/nomic-embed-text-v1.5-GGUF \
|
|
28
|
+
nomic-embed-text-v1.5.f16.gguf \
|
|
29
|
+
--local-dir "$HOME/.local/share/opencode-telescope/models" \
|
|
30
|
+
--local-dir-use-symlinks false
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Start The Embedding Server
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
llama-server \
|
|
37
|
+
-m "$HOME/.local/share/opencode-telescope/models/nomic-embed-text-v1.5.f16.gguf" \
|
|
38
|
+
--embedding \
|
|
39
|
+
--pooling mean \
|
|
40
|
+
-c 8192 \
|
|
41
|
+
-ub 8192 \
|
|
42
|
+
--host 127.0.0.1 \
|
|
43
|
+
--port 8081
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Smoke test the server:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
curl http://127.0.0.1:8081/health
|
|
50
|
+
|
|
51
|
+
curl http://127.0.0.1:8081/v1/embeddings \
|
|
52
|
+
-H "Content-Type: application/json" \
|
|
53
|
+
-d '{"input":"search_query: find the auth caching discussion","model":"nomic-embed-text-v1.5","encoding_format":"float"}'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Configure Telescope
|
|
57
|
+
|
|
58
|
+
Telescope defaults to `http://127.0.0.1:8081`, so most local setups only need the server running before opening OpenCode.
|
|
59
|
+
|
|
60
|
+
Optional environment variables:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
OPENCODE_TELESCOPE_EMBED_BASE_URL=http://127.0.0.1:8081
|
|
64
|
+
OPENCODE_TELESCOPE_EMBED_MODEL=nomic-embed-text-v1.5
|
|
65
|
+
OPENCODE_TELESCOPE_HYBRID_ALPHA=0.45
|
|
66
|
+
OPENCODE_TELESCOPE_DISABLE_VECTOR=1
|
|
67
|
+
OPENCODE_TELESCOPE_SQLITE_LIB=/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib
|
|
68
|
+
OPENCODE_TELESCOPE_SQLITE_VEC_EXT=/path/to/vec0
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`OPENCODE_TELESCOPE_HYBRID_ALPHA` controls the blend between keyword and vector ranking. `0` favors keyword search; `1` favors semantic vector search. The default is `0.45`.
|
|
72
|
+
|
|
73
|
+
Use `OPENCODE_TELESCOPE_DISABLE_VECTOR=1` to force keyword-only search.
|
|
74
|
+
|
|
75
|
+
## How It Works
|
|
76
|
+
|
|
77
|
+
1. Telescope reads OpenCode's SQLite session database in read-only mode.
|
|
78
|
+
2. It extracts searchable conversation documents into a sidecar index.
|
|
79
|
+
3. Keyword search uses local SQLite FTS.
|
|
80
|
+
4. Semantic search embeds documents with `search_document: ` and queries with `search_query: ` prefixes.
|
|
81
|
+
5. Vector rows are stored with `sqlite-vec` in the sidecar database.
|
|
82
|
+
6. Results are merged and ranked with hybrid keyword/vector scoring.
|
|
83
|
+
|
|
84
|
+
If the embedding server or vector extension is unavailable, Telescope keeps keyword search working and marks vector search unavailable internally.
|
|
85
|
+
|
|
86
|
+
## Troubleshooting
|
|
87
|
+
|
|
88
|
+
- If semantic results do not appear, confirm `curl http://127.0.0.1:8081/health` returns success.
|
|
89
|
+
- On macOS, install Homebrew SQLite if extension loading fails: `brew install sqlite`.
|
|
90
|
+
- Set `OPENCODE_TELESCOPE_SQLITE_LIB` if SQLite is installed somewhere other than Homebrew's default path.
|
|
91
|
+
- Set `OPENCODE_TELESCOPE_SQLITE_VEC_EXT` only when you need to load a specific `vec0` extension file.
|
|
92
|
+
- Set `OPENCODE_TELESCOPE_DISABLE_VECTOR=1` when you want fast keyword-only search or are debugging local embedding setup.
|
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.23",
|
|
5
|
+
"description": "OpenCode TUI plugin for fuzzy and semantic search across local conversation history, session transcripts, and 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,19 @@
|
|
|
21
21
|
"search",
|
|
22
22
|
"session",
|
|
23
23
|
"conversation",
|
|
24
|
+
"conversation-search",
|
|
25
|
+
"semantic-search",
|
|
26
|
+
"vector-search",
|
|
27
|
+
"embeddings",
|
|
28
|
+
"sqlite-vec",
|
|
24
29
|
"history",
|
|
30
|
+
"ai-chat-history",
|
|
31
|
+
"llm-history",
|
|
25
32
|
"fuzzy",
|
|
26
33
|
"fuzzy-finder",
|
|
27
34
|
"grep",
|
|
28
35
|
"finder",
|
|
36
|
+
"opencode-tui",
|
|
29
37
|
"neovim",
|
|
30
38
|
"chat-history"
|
|
31
39
|
],
|
|
@@ -39,6 +47,8 @@
|
|
|
39
47
|
},
|
|
40
48
|
"files": [
|
|
41
49
|
"assets",
|
|
50
|
+
"docs/index.html",
|
|
51
|
+
"docs/semantic-search.md",
|
|
42
52
|
"README.md",
|
|
43
53
|
"LICENSE",
|
|
44
54
|
"tui.tsx",
|
|
@@ -56,6 +66,9 @@
|
|
|
56
66
|
"import": "./tui.tsx"
|
|
57
67
|
}
|
|
58
68
|
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"sqlite-vec": "^0.1.9"
|
|
71
|
+
},
|
|
59
72
|
"peerDependencies": {
|
|
60
73
|
"@opencode-ai/plugin": "*",
|
|
61
74
|
"@opentui/core": "*",
|
package/search.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
2
3
|
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
3
4
|
import { homedir } from "node:os"
|
|
4
5
|
import path from "node:path"
|
|
5
6
|
import { debug } from "./ui/debug.ts"
|
|
7
|
+
import { LlamaEmbeddingClient } from "./search/embedding.ts"
|
|
6
8
|
|
|
7
9
|
export type SearchResult = {
|
|
8
10
|
id: string
|
|
@@ -63,6 +65,46 @@ export type ToolState = {
|
|
|
63
65
|
error?: string
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
export type SemanticConfig = {
|
|
69
|
+
embedBaseUrl: string
|
|
70
|
+
embedModel?: string
|
|
71
|
+
disableVector: boolean
|
|
72
|
+
sqliteLibPath?: string
|
|
73
|
+
sqliteVecExtension?: string
|
|
74
|
+
hybridAlpha: number
|
|
75
|
+
documentPrefix: string
|
|
76
|
+
queryPrefix: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type VectorState = "enabled" | "disabled" | "unavailable" | "stale"
|
|
80
|
+
|
|
81
|
+
export const DEFAULT_EMBED_BASE_URL = "http://127.0.0.1:8081"
|
|
82
|
+
export const DEFAULT_DOCUMENT_PREFIX = "search_document: "
|
|
83
|
+
export const DEFAULT_QUERY_PREFIX = "search_query: "
|
|
84
|
+
export const DEFAULT_HYBRID_ALPHA = 0.45
|
|
85
|
+
|
|
86
|
+
export function parseSemanticConfig(env: Record<string, string | undefined> = process.env): SemanticConfig {
|
|
87
|
+
return {
|
|
88
|
+
embedBaseUrl: env.OPENCODE_TELESCOPE_EMBED_BASE_URL ?? DEFAULT_EMBED_BASE_URL,
|
|
89
|
+
embedModel: env.OPENCODE_TELESCOPE_EMBED_MODEL || undefined,
|
|
90
|
+
disableVector: env.OPENCODE_TELESCOPE_DISABLE_VECTOR === "1" || env.OPENCODE_TELESCOPE_DISABLE_VECTOR === "true",
|
|
91
|
+
sqliteLibPath: env.OPENCODE_TELESCOPE_SQLITE_LIB || undefined,
|
|
92
|
+
sqliteVecExtension: env.OPENCODE_TELESCOPE_SQLITE_VEC_EXT || undefined,
|
|
93
|
+
hybridAlpha: parseAlpha(env.OPENCODE_TELESCOPE_HYBRID_ALPHA),
|
|
94
|
+
documentPrefix: DEFAULT_DOCUMENT_PREFIX,
|
|
95
|
+
queryPrefix: DEFAULT_QUERY_PREFIX,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseAlpha(value: string | undefined) {
|
|
100
|
+
if (!value) return DEFAULT_HYBRID_ALPHA
|
|
101
|
+
const parsed = Number(value)
|
|
102
|
+
if (!Number.isFinite(parsed)) return DEFAULT_HYBRID_ALPHA
|
|
103
|
+
if (parsed < 0) return 0
|
|
104
|
+
if (parsed > 1) return 1
|
|
105
|
+
return parsed
|
|
106
|
+
}
|
|
107
|
+
|
|
66
108
|
type Row = {
|
|
67
109
|
id: string
|
|
68
110
|
message_id: string
|
|
@@ -80,6 +122,23 @@ type IndexSourceRow = Omit<Row, "text"> & {
|
|
|
80
122
|
data: string
|
|
81
123
|
}
|
|
82
124
|
|
|
125
|
+
export type DocumentRow = {
|
|
126
|
+
doc_id: string
|
|
127
|
+
part_id: string
|
|
128
|
+
message_id: string
|
|
129
|
+
session_id: string
|
|
130
|
+
session_title: string
|
|
131
|
+
directory: string
|
|
132
|
+
role: string
|
|
133
|
+
part_type: string
|
|
134
|
+
tool: string | null
|
|
135
|
+
time_created: number
|
|
136
|
+
chunk_index: number
|
|
137
|
+
text: string
|
|
138
|
+
source_hash: string
|
|
139
|
+
extractor_version: string
|
|
140
|
+
}
|
|
141
|
+
|
|
83
142
|
type ConversationRow = {
|
|
84
143
|
id: string
|
|
85
144
|
message_id: string
|
|
@@ -734,6 +793,7 @@ function ensureSearchIndex(source: Database, sourcePath: string, options?: { reb
|
|
|
734
793
|
const indexPath = searchIndexPath(sourcePath)
|
|
735
794
|
if (!_indexDb || _indexDbPath !== indexPath) {
|
|
736
795
|
_indexDb?.close()
|
|
796
|
+
configureCustomSQLite()
|
|
737
797
|
_indexDb = new Database(indexPath)
|
|
738
798
|
_indexDbPath = indexPath
|
|
739
799
|
migrateSearchIndex(_indexDb)
|
|
@@ -773,7 +833,8 @@ function scheduleBackgroundIndexRebuild(dbPath: string) {
|
|
|
773
833
|
;(timer as { unref?: () => void }).unref?.()
|
|
774
834
|
}
|
|
775
835
|
|
|
776
|
-
const SEARCH_INDEX_VERSION = "
|
|
836
|
+
const SEARCH_INDEX_VERSION = "7"
|
|
837
|
+
const DOCUMENT_EXTRACTOR_VERSION = "1"
|
|
777
838
|
|
|
778
839
|
function searchIndexPath(sourcePath: string) {
|
|
779
840
|
const parsed = path.parse(sourcePath)
|
|
@@ -786,6 +847,24 @@ function migrateSearchIndex(db: Database) {
|
|
|
786
847
|
key TEXT PRIMARY KEY,
|
|
787
848
|
value TEXT NOT NULL
|
|
788
849
|
);
|
|
850
|
+
CREATE TABLE IF NOT EXISTS document(
|
|
851
|
+
rowid INTEGER PRIMARY KEY,
|
|
852
|
+
doc_id TEXT UNIQUE NOT NULL,
|
|
853
|
+
part_id TEXT NOT NULL,
|
|
854
|
+
message_id TEXT NOT NULL,
|
|
855
|
+
session_id TEXT NOT NULL,
|
|
856
|
+
session_title TEXT NOT NULL,
|
|
857
|
+
directory TEXT NOT NULL,
|
|
858
|
+
role TEXT NOT NULL,
|
|
859
|
+
part_type TEXT NOT NULL,
|
|
860
|
+
tool TEXT,
|
|
861
|
+
time_created INTEGER NOT NULL,
|
|
862
|
+
chunk_index INTEGER NOT NULL,
|
|
863
|
+
text TEXT NOT NULL,
|
|
864
|
+
source_hash TEXT NOT NULL,
|
|
865
|
+
extractor_version TEXT NOT NULL,
|
|
866
|
+
indexed_at INTEGER NOT NULL
|
|
867
|
+
);
|
|
789
868
|
CREATE VIRTUAL TABLE IF NOT EXISTS document_fts USING fts5(
|
|
790
869
|
id UNINDEXED,
|
|
791
870
|
message_id UNINDEXED,
|
|
@@ -895,7 +974,13 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
895
974
|
AND json_extract(m.data, '$.role') IN ('user', 'assistant')
|
|
896
975
|
ORDER BY p.time_created DESC
|
|
897
976
|
`).all()
|
|
898
|
-
const
|
|
977
|
+
const now = Date.now()
|
|
978
|
+
const config = parseSemanticConfig()
|
|
979
|
+
const insertDoc = index.query<unknown, [string, string, string, string, string, string, string, string, string | null, number, number, string, string, string, number]>(`
|
|
980
|
+
INSERT INTO document(doc_id, part_id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, chunk_index, text, source_hash, extractor_version, indexed_at)
|
|
981
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
982
|
+
`)
|
|
983
|
+
const insertFts = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
|
|
899
984
|
INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
|
|
900
985
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
901
986
|
`)
|
|
@@ -905,10 +990,30 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
905
990
|
`)
|
|
906
991
|
index.exec("BEGIN IMMEDIATE")
|
|
907
992
|
try {
|
|
993
|
+
index.exec("DELETE FROM document")
|
|
908
994
|
index.exec("DELETE FROM document_fts")
|
|
909
995
|
index.exec("DELETE FROM document_index")
|
|
910
996
|
for (const row of rows.flatMap(indexSourceRowToRows)) {
|
|
911
|
-
|
|
997
|
+
const docID = `telescope:${row.session_id}:${row.message_id}:${row.id}:0`
|
|
998
|
+
const sourceHash = hashPartData({ session_id: row.session_id, message_id: row.message_id, part_id: row.id })
|
|
999
|
+
insertDoc.run(
|
|
1000
|
+
docID,
|
|
1001
|
+
row.id,
|
|
1002
|
+
row.message_id,
|
|
1003
|
+
row.session_id,
|
|
1004
|
+
row.session_title ?? "Untitled session",
|
|
1005
|
+
row.directory,
|
|
1006
|
+
row.role,
|
|
1007
|
+
row.part_type ?? "text",
|
|
1008
|
+
row.tool ?? null,
|
|
1009
|
+
row.time_created,
|
|
1010
|
+
0,
|
|
1011
|
+
row.text,
|
|
1012
|
+
sourceHash,
|
|
1013
|
+
DOCUMENT_EXTRACTOR_VERSION,
|
|
1014
|
+
now,
|
|
1015
|
+
)
|
|
1016
|
+
insertFts.run(
|
|
912
1017
|
row.id,
|
|
913
1018
|
row.message_id,
|
|
914
1019
|
row.session_id,
|
|
@@ -937,6 +1042,13 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
937
1042
|
setMeta(index, "source_data_version", String(state.dataVersion))
|
|
938
1043
|
setMeta(index, "source_mtime_ms", String(state.mtimeMs))
|
|
939
1044
|
setMeta(index, "index_version", SEARCH_INDEX_VERSION)
|
|
1045
|
+
setMeta(index, "schema_version", "2")
|
|
1046
|
+
setMeta(index, "extractor_version", DOCUMENT_EXTRACTOR_VERSION)
|
|
1047
|
+
setMeta(index, "ranking_version", "1")
|
|
1048
|
+
setMeta(index, "embedding_base_url", config.embedBaseUrl)
|
|
1049
|
+
setMeta(index, "document_prefix", config.documentPrefix)
|
|
1050
|
+
setMeta(index, "query_prefix", config.queryPrefix)
|
|
1051
|
+
if (config.embedModel) setMeta(index, "embedding_model", config.embedModel)
|
|
940
1052
|
index.exec("COMMIT")
|
|
941
1053
|
} catch (err) {
|
|
942
1054
|
index.exec("ROLLBACK")
|
|
@@ -944,6 +1056,321 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
944
1056
|
} finally {
|
|
945
1057
|
debug.timeEnd("fts:rebuild")
|
|
946
1058
|
}
|
|
1059
|
+
|
|
1060
|
+
if (config.disableVector) {
|
|
1061
|
+
setMeta(index, "vector_state", "disabled")
|
|
1062
|
+
} else {
|
|
1063
|
+
setupVectorTable(index, config, searchIndexPath(sourcePath))
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function hashPartData(value: { session_id: string; message_id: string; part_id: string }) {
|
|
1068
|
+
return createHash("sha256").update(`${value.session_id}:${value.message_id}:${value.part_id}`).digest("hex")
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
let customSQLiteConfigured = false
|
|
1072
|
+
|
|
1073
|
+
function configureCustomSQLite() {
|
|
1074
|
+
if (customSQLiteConfigured) return
|
|
1075
|
+
const config = parseSemanticConfig()
|
|
1076
|
+
if (config.disableVector) return
|
|
1077
|
+
customSQLiteConfigured = true
|
|
1078
|
+
|
|
1079
|
+
const candidates = [
|
|
1080
|
+
config.sqliteLibPath,
|
|
1081
|
+
process.platform === "darwin" ? "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib" : undefined,
|
|
1082
|
+
process.platform === "darwin" ? "/usr/local/opt/sqlite/lib/libsqlite3.dylib" : undefined,
|
|
1083
|
+
].filter((item): item is string => Boolean(item))
|
|
1084
|
+
|
|
1085
|
+
for (const candidate of candidates) {
|
|
1086
|
+
if (existsSync(candidate)) {
|
|
1087
|
+
try {
|
|
1088
|
+
Database.setCustomSQLite(candidate)
|
|
1089
|
+
debug.log("custom-sqlite:set", { path: candidate })
|
|
1090
|
+
return
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
debug.log("custom-sqlite:error", { path: candidate, error: err instanceof Error ? err.message : String(err) })
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function importPackage(specifier: string) {
|
|
1099
|
+
return new Function("specifier", "return import(specifier)")(specifier) as Promise<{ default?: { load?: (db: Database) => void } }>
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
async function loadVecExtension(db: Database): Promise<boolean> {
|
|
1103
|
+
try {
|
|
1104
|
+
const sqliteVec = await importPackage("sqlite-vec").catch(() => undefined)
|
|
1105
|
+
if (sqliteVec?.default?.load) {
|
|
1106
|
+
sqliteVec.default.load(db)
|
|
1107
|
+
debug.log("vector:extension:loaded", { source: "npm" })
|
|
1108
|
+
return true
|
|
1109
|
+
}
|
|
1110
|
+
} catch {
|
|
1111
|
+
debug.log("vector:extension:npm-failed")
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const config = parseSemanticConfig()
|
|
1115
|
+
const explicitPath = config.sqliteVecExtension || process.env.OPENCODE_TELESCOPE_SQLITE_VEC_EXT
|
|
1116
|
+
if (explicitPath && existsSync(explicitPath)) {
|
|
1117
|
+
try {
|
|
1118
|
+
db.loadExtension(explicitPath)
|
|
1119
|
+
debug.log("vector:extension:loaded", { source: "path", path: explicitPath })
|
|
1120
|
+
return true
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
debug.log("vector:extension:path-failed", { path: explicitPath, error: err instanceof Error ? err.message : String(err) })
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return false
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function setupVectorTable(index: Database, config: SemanticConfig, indexPath: string) {
|
|
1130
|
+
setMeta(index, "vector_state", "stale")
|
|
1131
|
+
rebuildVectorIndex(indexPath, config).catch((err: Error) => {
|
|
1132
|
+
debug.log("vector:rebuild:error", err instanceof Error ? err.message : String(err))
|
|
1133
|
+
})
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async function rebuildVectorIndex(indexPath: string, config: SemanticConfig) {
|
|
1137
|
+
configureCustomSQLite()
|
|
1138
|
+
const db = new Database(indexPath)
|
|
1139
|
+
try {
|
|
1140
|
+
const loaded = await loadVecExtension(db)
|
|
1141
|
+
if (!loaded) {
|
|
1142
|
+
setMeta(db, "vector_state", "unavailable")
|
|
1143
|
+
return
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const client = new LlamaEmbeddingClient({
|
|
1147
|
+
baseUrl: config.embedBaseUrl,
|
|
1148
|
+
model: config.embedModel,
|
|
1149
|
+
documentPrefix: config.documentPrefix,
|
|
1150
|
+
queryPrefix: config.queryPrefix,
|
|
1151
|
+
})
|
|
1152
|
+
const healthy = await client.health()
|
|
1153
|
+
if (!healthy) {
|
|
1154
|
+
setMeta(db, "vector_state", "unavailable")
|
|
1155
|
+
return
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const docs = db.query<{ rowid: number; text: string }, []>("SELECT rowid, text FROM document ORDER BY rowid ASC").all()
|
|
1159
|
+
if (!docs.length) {
|
|
1160
|
+
setMeta(db, "vector_state", "enabled")
|
|
1161
|
+
return
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
debug.log("vector:embed:start", { count: docs.length })
|
|
1165
|
+
const embeddings = await client.embedDocuments(docs.map((d) => d.text))
|
|
1166
|
+
const dims = embeddings[0]?.length
|
|
1167
|
+
if (!dims) {
|
|
1168
|
+
setMeta(db, "vector_state", "unavailable")
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
db.exec("DROP TABLE IF EXISTS document_vec")
|
|
1173
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS document_vec USING vec0(embedding float[${dims}])`)
|
|
1174
|
+
debug.log("vector:table:recreated", { dimensions: dims })
|
|
1175
|
+
|
|
1176
|
+
const insert = db.prepare<unknown, [number, Float32Array]>("INSERT INTO document_vec(rowid, embedding) VALUES (?, vec_f32(?))")
|
|
1177
|
+
const transaction = db.transaction(() => {
|
|
1178
|
+
for (const [i, doc] of docs.entries()) {
|
|
1179
|
+
insert.run(doc.rowid, embeddings[i])
|
|
1180
|
+
}
|
|
1181
|
+
})
|
|
1182
|
+
transaction()
|
|
1183
|
+
|
|
1184
|
+
setMeta(db, "vector_state", "enabled")
|
|
1185
|
+
setMeta(db, "embedding_dimensions", String(dims))
|
|
1186
|
+
if (config.embedModel) setMeta(db, "embedding_model", config.embedModel)
|
|
1187
|
+
debug.log("vector:rebuild:done", { vectors: docs.length, dimensions: dims })
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
setMeta(db, "vector_state", "unavailable")
|
|
1190
|
+
debug.log("vector:rebuild:error", err instanceof Error ? err.message : String(err))
|
|
1191
|
+
} finally {
|
|
1192
|
+
db.close()
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export type ScoredRow = Row & {
|
|
1197
|
+
score: number
|
|
1198
|
+
keywordScore: number
|
|
1199
|
+
vectorScore: number
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function searchVector(index: Database, embedding: Float32Array, limit: number): Row[] {
|
|
1203
|
+
const count = index.query<{ count: number }, []>("SELECT COUNT(*) as count FROM document_vec").get()?.count ?? 0
|
|
1204
|
+
if (!count) return []
|
|
1205
|
+
const k = Math.min(count, Math.max(limit * 4, 200))
|
|
1206
|
+
return index.query<Row, [Float32Array, number]>(`
|
|
1207
|
+
SELECT d.part_id AS id, d.message_id, d.session_id, d.session_title, d.directory, d.role,
|
|
1208
|
+
d.part_type, d.tool, CAST(d.time_created AS INTEGER) AS time_created, d.text
|
|
1209
|
+
FROM document_vec v
|
|
1210
|
+
JOIN document d ON d.rowid = v.rowid
|
|
1211
|
+
WHERE v.embedding MATCH vec_f32(?) AND k = ?
|
|
1212
|
+
ORDER BY v.distance
|
|
1213
|
+
`).all(embedding, k)
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
export function hybridBlend(keyword: Row[], vector: Row[], alpha: number): ScoredRow[] {
|
|
1217
|
+
const merged = new Map<string, ScoredRow>()
|
|
1218
|
+
const defaultScore = 1 / Math.max(keyword.length, 1)
|
|
1219
|
+
|
|
1220
|
+
for (const [i, row] of keyword.entries()) {
|
|
1221
|
+
merged.set(row.id, {
|
|
1222
|
+
...row,
|
|
1223
|
+
score: 0,
|
|
1224
|
+
keywordScore: keyword.length > 1 ? 1 - i / (keyword.length - 1) : 1,
|
|
1225
|
+
vectorScore: 0,
|
|
1226
|
+
})
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
for (const [i, row] of vector.entries()) {
|
|
1230
|
+
const existing = merged.get(row.id)
|
|
1231
|
+
const vectorScore = vector.length > 1 ? 1 - i / (vector.length - 1) : 1
|
|
1232
|
+
if (existing) {
|
|
1233
|
+
existing.vectorScore = vectorScore
|
|
1234
|
+
} else {
|
|
1235
|
+
merged.set(row.id, {
|
|
1236
|
+
...row,
|
|
1237
|
+
score: 0,
|
|
1238
|
+
keywordScore: defaultScore,
|
|
1239
|
+
vectorScore,
|
|
1240
|
+
})
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const keywordValues = [...merged.values()].map((r) => r.keywordScore)
|
|
1245
|
+
const vectorValues = [...merged.values()].map((r) => r.vectorScore)
|
|
1246
|
+
const kwMin = Math.min(...keywordValues)
|
|
1247
|
+
const kwMax = Math.max(...keywordValues)
|
|
1248
|
+
const vecMin = Math.min(...vectorValues)
|
|
1249
|
+
const vecMax = Math.max(...vectorValues)
|
|
1250
|
+
|
|
1251
|
+
const normalized = [...merged.values()].map((r) => {
|
|
1252
|
+
const kn = kwMax === kwMin ? 0.5 : (r.keywordScore - kwMin) / (kwMax - kwMin)
|
|
1253
|
+
const vn = vecMax === vecMin ? 0.5 : (r.vectorScore - vecMin) / (vecMax - vecMin)
|
|
1254
|
+
return {
|
|
1255
|
+
...r,
|
|
1256
|
+
keywordScore: kn,
|
|
1257
|
+
vectorScore: vn,
|
|
1258
|
+
score: (1 - alpha) * kn + alpha * vn,
|
|
1259
|
+
}
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
return normalized.sort((a, b) => b.score - a.score)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
export type HybridSearchOptions = {
|
|
1266
|
+
limit?: number
|
|
1267
|
+
offset?: number
|
|
1268
|
+
directory?: string
|
|
1269
|
+
role?: SearchRole
|
|
1270
|
+
dbPath?: string
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
export async function performSearch(query: string, options?: HybridSearchOptions): Promise<SearchResult[]> {
|
|
1274
|
+
if (!query.trim()) return searchSessionMessages(query, options)
|
|
1275
|
+
const config = parseSemanticConfig()
|
|
1276
|
+
if (!config.disableVector) {
|
|
1277
|
+
try {
|
|
1278
|
+
return await semanticSearchSessionMessages(query, options)
|
|
1279
|
+
} catch {
|
|
1280
|
+
debug.log("query:hybrid:fallback", { message: "hybrid search failed, falling back to keyword" })
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return searchSessionMessages(query, options)
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
export async function semanticSearchSessionMessages(query: string, options?: HybridSearchOptions): Promise<SearchResult[]> {
|
|
1287
|
+
const term = query.trim()
|
|
1288
|
+
if (!term) return []
|
|
1289
|
+
if (options?.dbPath === ":memory:") return []
|
|
1290
|
+
const dbPath = options?.dbPath ?? resolveDatabasePath()
|
|
1291
|
+
const db = getDb(dbPath)
|
|
1292
|
+
const limit = options?.limit ?? 80
|
|
1293
|
+
const dir = options?.directory
|
|
1294
|
+
const role = options?.role
|
|
1295
|
+
|
|
1296
|
+
const index = ensureSearchIndex(db, dbPath)
|
|
1297
|
+
if (!index) return []
|
|
1298
|
+
|
|
1299
|
+
const keyword = indexedTextRows(db, dbPath, limit, term, dir, options?.offset, role) ?? []
|
|
1300
|
+
const config = parseSemanticConfig()
|
|
1301
|
+
|
|
1302
|
+
let vector: Row[] = []
|
|
1303
|
+
if (!config.disableVector) {
|
|
1304
|
+
const vecState = getMeta(index, "vector_state")
|
|
1305
|
+
if (vecState === "enabled") {
|
|
1306
|
+
try {
|
|
1307
|
+
const client = new LlamaEmbeddingClient({
|
|
1308
|
+
baseUrl: config.embedBaseUrl,
|
|
1309
|
+
model: config.embedModel,
|
|
1310
|
+
documentPrefix: config.documentPrefix,
|
|
1311
|
+
queryPrefix: config.queryPrefix,
|
|
1312
|
+
})
|
|
1313
|
+
const embedding = await client.embedQuery(term)
|
|
1314
|
+
vector = searchVector(index, embedding, limit)
|
|
1315
|
+
debug.log("query:vector:results", { count: vector.length })
|
|
1316
|
+
} catch (err) {
|
|
1317
|
+
debug.log("query:vector:error", err instanceof Error ? err.message : String(err))
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const merged = keyword.length || vector.length
|
|
1323
|
+
? hybridBlend(keyword, vector, config.hybridAlpha)
|
|
1324
|
+
: []
|
|
1325
|
+
|
|
1326
|
+
const results: SearchResult[] = []
|
|
1327
|
+
const seen = new Set<string>()
|
|
1328
|
+
for (const row of merged) {
|
|
1329
|
+
const result = rowToSearchResult(row, term) ?? rowToVectorResult(row)
|
|
1330
|
+
if (result) {
|
|
1331
|
+
seen.add(row.id)
|
|
1332
|
+
results.push(result)
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
for (const row of vector) {
|
|
1337
|
+
if (!seen.has(row.id)) {
|
|
1338
|
+
const result = rowToSearchResult(row, term) ?? rowToVectorResult(row)
|
|
1339
|
+
if (result) results.push(result)
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
return results
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
export function rowToVectorResult(row: Row): SearchResult | undefined {
|
|
1347
|
+
const text = row.text.trim()
|
|
1348
|
+
if (!text) return
|
|
1349
|
+
const excerpt = text.slice(0, 200)
|
|
1350
|
+
return {
|
|
1351
|
+
id: row.id,
|
|
1352
|
+
messageID: row.message_id,
|
|
1353
|
+
sessionID: row.session_id,
|
|
1354
|
+
sessionTitle: row.session_title || "Untitled session",
|
|
1355
|
+
directory: row.directory,
|
|
1356
|
+
role: row.role,
|
|
1357
|
+
partType: row.part_type ?? "text",
|
|
1358
|
+
tool: row.tool ?? undefined,
|
|
1359
|
+
timeCreated: row.time_created,
|
|
1360
|
+
snippet: excerpt,
|
|
1361
|
+
matchStart: -1,
|
|
1362
|
+
matchEnd: -1,
|
|
1363
|
+
before: "",
|
|
1364
|
+
match: "",
|
|
1365
|
+
after: excerpt,
|
|
1366
|
+
excerpt,
|
|
1367
|
+
previewBefore: text.slice(0, Math.min(text.length, 1400)),
|
|
1368
|
+
previewMatch: "",
|
|
1369
|
+
previewAfter: "",
|
|
1370
|
+
previewMode: "markdown" as const,
|
|
1371
|
+
previewHighlight: false,
|
|
1372
|
+
text,
|
|
1373
|
+
}
|
|
947
1374
|
}
|
|
948
1375
|
|
|
949
1376
|
function ftsQuery(query: string) {
|
package/telescope.tsx
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
loadConversationAfter,
|
|
10
10
|
loadConversationAround,
|
|
11
11
|
loadConversationBefore,
|
|
12
|
+
parseSemanticConfig,
|
|
13
|
+
performSearch,
|
|
12
14
|
recentSessionMessages,
|
|
13
15
|
resolveDatabasePath,
|
|
14
16
|
searchSessionMessages,
|
|
@@ -35,6 +37,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
35
37
|
const [nextResultOffset, setNextResultOffset] = createSignal(0)
|
|
36
38
|
const [busy, setBusy] = createSignal(false)
|
|
37
39
|
const [error, setError] = createSignal("")
|
|
40
|
+
const [searchMode, setSearchMode] = createSignal<"keyword" | "hybrid">("keyword")
|
|
38
41
|
const [mode, setMode] = createSignal<"normal" | "insert">("normal")
|
|
39
42
|
const [loading, setLoading] = createSignal(true)
|
|
40
43
|
const [hasMore, setHasMore] = createSignal(true)
|
|
@@ -93,6 +96,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
93
96
|
let resultNavigationStarted = false
|
|
94
97
|
let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
|
|
95
98
|
let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
|
|
99
|
+
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
100
|
+
let lastFiredQuery = ""
|
|
101
|
+
const SEARCH_DEBOUNCE_MS = 200
|
|
96
102
|
type PreviewBeforeIntent = "passive-prefetch" | "explicit-scroll"
|
|
97
103
|
type PendingPreviewBefore = {
|
|
98
104
|
id: number
|
|
@@ -193,6 +199,58 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
193
199
|
})
|
|
194
200
|
})
|
|
195
201
|
|
|
202
|
+
const detectSearchMode = () => {
|
|
203
|
+
if (!query().trim()) return "keyword" as const
|
|
204
|
+
const config = parseSemanticConfig()
|
|
205
|
+
return config.disableVector ? "keyword" as const : "hybrid" as const
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const executeSearch = async (q: string, role: SearchRole | undefined, db: string, dir: string) => {
|
|
209
|
+
lastFiredQuery = q
|
|
210
|
+
const limit = q ? Math.min(searchBatchSize(), 80) : recentBatchSize()
|
|
211
|
+
setLoading(true)
|
|
212
|
+
if (q) setBusy(true)
|
|
213
|
+
setSearchMode(detectSearchMode())
|
|
214
|
+
debug.time("query:search")
|
|
215
|
+
try {
|
|
216
|
+
debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q || "(all recent)" })
|
|
217
|
+
const batch = q
|
|
218
|
+
? await performSearch(q, { limit, offset: 0, dbPath: db, directory: dir, role })
|
|
219
|
+
: recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
|
|
220
|
+
debug.log("bootstrap:search:done", { rows: batch.length, limit, mode: searchMode() })
|
|
221
|
+
solidBatch(() => {
|
|
222
|
+
setResults(batch)
|
|
223
|
+
setResultBaseOffset(0)
|
|
224
|
+
setNextResultOffset(batch.length)
|
|
225
|
+
setHasMore(batch.length >= limit)
|
|
226
|
+
setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
|
|
227
|
+
setSelected(0)
|
|
228
|
+
})
|
|
229
|
+
} catch (err) {
|
|
230
|
+
debug.log("bootstrap:search:error", err instanceof Error ? err.message : String(err))
|
|
231
|
+
solidBatch(() => {
|
|
232
|
+
setResults([])
|
|
233
|
+
setResultBaseOffset(0)
|
|
234
|
+
setNextResultOffset(0)
|
|
235
|
+
setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
|
|
236
|
+
})
|
|
237
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
238
|
+
} finally {
|
|
239
|
+
debug.timeEnd("query:search")
|
|
240
|
+
setBusy(false)
|
|
241
|
+
setLoading(false)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const scheduleSearch = (q: string, role: SearchRole | undefined, db: string, dir: string) => {
|
|
246
|
+
if (searchTimer) clearTimeout(searchTimer)
|
|
247
|
+
if (q === lastFiredQuery && results().length > 0) return
|
|
248
|
+
searchTimer = setTimeout(() => {
|
|
249
|
+
searchTimer = undefined
|
|
250
|
+
executeSearch(q, role, db, dir)
|
|
251
|
+
}, q ? SEARCH_DEBOUNCE_MS : 1)
|
|
252
|
+
}
|
|
253
|
+
|
|
196
254
|
createEffect(() => {
|
|
197
255
|
const q = query().trim()
|
|
198
256
|
const role = ownerRole()
|
|
@@ -211,76 +269,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
211
269
|
const db = dbPath()
|
|
212
270
|
const dir = directory
|
|
213
271
|
|
|
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))
|
|
272
|
+
scheduleSearch(q, role, db, dir)
|
|
273
|
+
onCleanup(() => {
|
|
274
|
+
if (searchTimer) clearTimeout(searchTimer)
|
|
275
|
+
searchTimer = undefined
|
|
276
|
+
})
|
|
281
277
|
})
|
|
282
278
|
|
|
283
|
-
const loadMoreResults = (advance = false) => {
|
|
279
|
+
const loadMoreResults = async (advance = false) => {
|
|
284
280
|
if (advance) advanceSelectionAfterLoad = true
|
|
285
281
|
if (!hasMore()) {
|
|
286
282
|
advanceSelectionAfterLoad = false
|
|
@@ -299,7 +295,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
299
295
|
debug.time("query:load-more")
|
|
300
296
|
try {
|
|
301
297
|
const batch = q
|
|
302
|
-
?
|
|
298
|
+
? await performSearch(q, { limit, offset, dbPath: db, directory: dir, role })
|
|
303
299
|
: recentSessionMessages({ limit, offset, dbPath: db, directory: dir, role })
|
|
304
300
|
const nextHasMore = batch.length >= limit
|
|
305
301
|
const nextLoadedUntil = offset + batch.length
|
|
@@ -778,6 +774,33 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
778
774
|
})
|
|
779
775
|
}
|
|
780
776
|
}
|
|
777
|
+
|
|
778
|
+
if (pending.intent === "passive-prefetch") {
|
|
779
|
+
const matchItem = selectedResult()
|
|
780
|
+
const scroll = previewScroll
|
|
781
|
+
if (matchItem && scroll) {
|
|
782
|
+
const layout = previewVirtualLayout()
|
|
783
|
+
const matchRow = layout.find((r) => r.part.id === matchItem.id)
|
|
784
|
+
if (matchRow) {
|
|
785
|
+
const viewportHeight = scroll.height
|
|
786
|
+
const desiredScrollTop = Math.max(0, matchRow.top - Math.floor(viewportHeight / 3))
|
|
787
|
+
const msSinceManual = Date.now() - lastPreviewScrollKeyMs
|
|
788
|
+
if (Math.abs(scroll.scrollTop - desiredScrollTop) >= 2 && msSinceManual > 700) {
|
|
789
|
+
const correctedWindow = previewWindowForLayout(layout, desiredScrollTop, viewportHeight)
|
|
790
|
+
setPreviewWindow(correctedWindow)
|
|
791
|
+
scroll.scrollTo(desiredScrollTop)
|
|
792
|
+
debug.log("preview:load-before:recenter", {
|
|
793
|
+
matchPartID: matchItem.id,
|
|
794
|
+
matchTop: matchRow.top,
|
|
795
|
+
scrollTop: scroll.scrollTop,
|
|
796
|
+
desiredScrollTop,
|
|
797
|
+
viewportHeight,
|
|
798
|
+
window: correctedWindow,
|
|
799
|
+
})
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
781
804
|
}, 1)
|
|
782
805
|
}
|
|
783
806
|
setHasMorePreviewBefore(page.hasMoreBefore)
|
|
@@ -971,7 +994,24 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
971
994
|
debug.log("preview:target-scroll:estimated", { targetID, targetTop: row.top, scrollTop: scroll.scrollTop, nextScrollTop: top, window: previewWindow() })
|
|
972
995
|
scroll.scrollTo(top)
|
|
973
996
|
updatePreviewWindow()
|
|
974
|
-
|
|
997
|
+
const scrollTopBeforeReal = scroll.scrollTop
|
|
998
|
+
setTimeout(() => {
|
|
999
|
+
scrollPreviewToTarget(scroll, targetID)
|
|
1000
|
+
const now = Date.now()
|
|
1001
|
+
const adjustmentMs = lastPreviewScrollKeyMs
|
|
1002
|
+
if (adjustmentMs > 0 && now - adjustmentMs > 300) {
|
|
1003
|
+
const targetTopReal = row.top
|
|
1004
|
+
const expectedScrollTop = Math.max(0, targetTopReal - Math.max(1, Math.floor(scroll.height / 3)))
|
|
1005
|
+
const drift = scroll.scrollTop - expectedScrollTop
|
|
1006
|
+
if (Math.abs(drift) >= 2) {
|
|
1007
|
+
const correctedScrollTop = scroll.scrollTop - drift
|
|
1008
|
+
const correctedWindow = previewWindowForLayout(previewVirtualLayout(), correctedScrollTop, scroll.height)
|
|
1009
|
+
setPreviewWindow(correctedWindow)
|
|
1010
|
+
scroll.scrollTo(correctedScrollTop)
|
|
1011
|
+
debug.log("preview:target-scroll:real-correct", { targetID, drift, scrollTopAfterReal: scroll.scrollTop, correctedScrollTop, window: correctedWindow })
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}, 1)
|
|
975
1015
|
}
|
|
976
1016
|
|
|
977
1017
|
let lastPreviewItemId = ""
|
|
@@ -1218,7 +1258,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
1218
1258
|
}}
|
|
1219
1259
|
flexGrow={1}
|
|
1220
1260
|
/>
|
|
1221
|
-
<text fg={theme().textMuted}>{busy() ? `searching ${ownerLabel()}` : loading() ? `loading ${ownerLabel()}` : query().trim() ? (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} hits` : `${ownerLabel()} 0 hits`) : (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} recent` : `${ownerLabel()} 0 recent`)}</text>
|
|
1261
|
+
<text fg={theme().textMuted}>{busy() ? `searching ${ownerLabel()}` : loading() ? `loading ${ownerLabel()}` : query().trim() ? (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} hits` : `${ownerLabel()} 0 hits`) + (query().trim() ? ` [${searchMode()}]` : "") : (results().length > 0 ? `${ownerLabel()} ${selected() + 1}/${nextResultOffset()} recent` : `${ownerLabel()} 0 recent`)}</text>
|
|
1222
1262
|
</box>
|
|
1223
1263
|
</box>
|
|
1224
1264
|
|
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) {
|