@bojackduy/opencode-telescope 0.1.3 → 0.1.5
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 +27 -3
- package/components/preview.tsx +31 -12
- package/components/result-list.tsx +18 -8
- package/package.json +12 -3
- package/search.ts +121 -67
- package/telescope.tsx +96 -9
- package/tsconfig.json +2 -1
- package/ui/debug.ts +25 -0
package/README.md
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
# opencode-telescope
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fuzzy search across all your OpenCode conversations — grep through session and chat history, find code snippets, and jump to any chat instantly.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> Inspired by [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) — a fuzzy finder for your conversation history.
|
|
6
|
+
|
|
7
|
+
## Use cases
|
|
8
|
+
|
|
9
|
+
- **"I know I discussed this somewhere"** — grep all your sessions by keyword
|
|
10
|
+
- **"Find that code snippet"** — search for code you saw in a past LLM response
|
|
11
|
+
- **"Revisit a decision"** — find the conversation where you chose approach X
|
|
12
|
+
- **"Session journal"** — browse your entire conversation history like a timeline
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Fuzzy grep** — search across all session messages by text
|
|
17
|
+
- **Live preview** — preview the matched conversation result before opening
|
|
18
|
+
- **Find & jump** — select any result and jump straight to that session
|
|
19
|
+
- **Neovim Telescope-style UX** — familiar `<leader>f` keybind and `/telescope` command
|
|
6
20
|
|
|
7
21
|
## Installation
|
|
8
22
|
|
|
@@ -22,4 +36,14 @@ Add the plugin to your `tui.json`:
|
|
|
22
36
|
|
|
23
37
|
## Usage
|
|
24
38
|
|
|
25
|
-
|
|
39
|
+
| Action | Key / Command |
|
|
40
|
+
|--------|--------------|
|
|
41
|
+
| Open search | `<leader>f` or `/telescope` |
|
|
42
|
+
| Type to filter | Fuzzy match against conversation text |
|
|
43
|
+
| Navigate results | `↑` / `↓` or `Ctrl+j` / `Ctrl+k` |
|
|
44
|
+
| Preview | Select a result to see the conversation preview |
|
|
45
|
+
| Open | Press `Enter` to jump to the selected session |
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
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.
|
package/components/preview.tsx
CHANGED
|
@@ -228,10 +228,20 @@ function searchResultPreviewPart(item: SearchResult): ConversationPreviewPart {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
function conversationMatch(part: ConversationPreviewPart, item: SearchResult) {
|
|
231
|
-
if (!part.target) return
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
231
|
+
if (!part.target || !item.match) return
|
|
232
|
+
const tokens = item.match.split(/\s+/)
|
|
233
|
+
const lowerText = part.text.toLowerCase()
|
|
234
|
+
let searchPos = 0
|
|
235
|
+
let firstStart = -1
|
|
236
|
+
let lastEnd = -1
|
|
237
|
+
for (const token of tokens) {
|
|
238
|
+
const index = lowerText.indexOf(token.toLowerCase(), searchPos)
|
|
239
|
+
if (index === -1) return
|
|
240
|
+
if (firstStart === -1) firstStart = index
|
|
241
|
+
searchPos = index + token.length
|
|
242
|
+
lastEnd = searchPos
|
|
243
|
+
}
|
|
244
|
+
return { start: firstStart, end: lastEnd }
|
|
235
245
|
}
|
|
236
246
|
|
|
237
247
|
function conversationMarkdown(part: ConversationPreviewPart, item: SearchResult) {
|
|
@@ -243,14 +253,23 @@ function conversationMarkdown(part: ConversationPreviewPart, item: SearchResult)
|
|
|
243
253
|
function matchExcerpt(text: string, query: string, radius = 80) {
|
|
244
254
|
const needle = query.trim()
|
|
245
255
|
if (!needle) return
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
const tokens = needle.split(/\s+/)
|
|
257
|
+
const lowerText = text.toLowerCase()
|
|
258
|
+
let searchPos = 0
|
|
259
|
+
let firstStart = -1
|
|
260
|
+
let lastEnd = -1
|
|
261
|
+
for (const token of tokens) {
|
|
262
|
+
const start = lowerText.indexOf(token.toLowerCase(), searchPos)
|
|
263
|
+
if (start === -1) return
|
|
264
|
+
if (firstStart === -1) firstStart = start
|
|
265
|
+
searchPos = start + token.length
|
|
266
|
+
lastEnd = searchPos
|
|
267
|
+
}
|
|
268
|
+
const beforeStart = Math.max(0, firstStart - radius)
|
|
269
|
+
const afterEnd = Math.min(text.length, lastEnd + radius)
|
|
251
270
|
return {
|
|
252
|
-
before: `${beforeStart > 0 ? "..." : ""}${text.slice(beforeStart,
|
|
253
|
-
match: text.slice(
|
|
254
|
-
after: `${text.slice(
|
|
271
|
+
before: `${beforeStart > 0 ? "..." : ""}${text.slice(beforeStart, firstStart).replace(/\s+/g, " ")}`,
|
|
272
|
+
match: text.slice(firstStart, lastEnd),
|
|
273
|
+
after: `${text.slice(lastEnd, afterEnd).replace(/\s+/g, " ")}${afterEnd < text.length ? "..." : ""}`,
|
|
255
274
|
}
|
|
256
275
|
}
|
|
@@ -49,6 +49,7 @@ export const ResultRow = (props: {
|
|
|
49
49
|
query={props.query}
|
|
50
50
|
active={props.active}
|
|
51
51
|
theme={props.theme}
|
|
52
|
+
maxWidth={props.width}
|
|
52
53
|
/>
|
|
53
54
|
</box>
|
|
54
55
|
)
|
|
@@ -88,14 +89,23 @@ const HighlightedText = (props: {
|
|
|
88
89
|
query: string
|
|
89
90
|
active: boolean
|
|
90
91
|
theme: TuiThemeCurrent
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
92
|
+
maxWidth: number
|
|
93
|
+
}) => {
|
|
94
|
+
const textMax = Math.max(10, props.maxWidth - 2)
|
|
95
|
+
const sideMax = Math.floor(textMax * 0.35)
|
|
96
|
+
const matchMax = textMax - sideMax * 2
|
|
97
|
+
const before = truncate(props.before, sideMax)
|
|
98
|
+
const match = truncate(props.match || props.query, matchMax)
|
|
99
|
+
const after = truncate(props.after, sideMax)
|
|
100
|
+
return (
|
|
101
|
+
<text wrapMode="none" overflow="hidden">
|
|
102
|
+
<span style={{ fg: props.theme.textMuted }}> </span>
|
|
103
|
+
<span style={{ fg: props.active ? props.theme.text : props.theme.textMuted }}>{before}</span>
|
|
104
|
+
<span style={{ fg: props.theme.warning, bold: true }}>{match}</span>
|
|
105
|
+
<span style={{ fg: props.active ? props.theme.text : props.theme.textMuted }}>{after}</span>
|
|
106
|
+
</text>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
99
109
|
|
|
100
110
|
function sessionTitleWidth(width: number) {
|
|
101
111
|
if (width >= 54) return 28
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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.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",
|
|
8
8
|
"repository": {
|
|
@@ -18,7 +18,16 @@
|
|
|
18
18
|
"opencode-plugin",
|
|
19
19
|
"tui",
|
|
20
20
|
"telescope",
|
|
21
|
-
"search"
|
|
21
|
+
"search",
|
|
22
|
+
"session",
|
|
23
|
+
"conversation",
|
|
24
|
+
"history",
|
|
25
|
+
"fuzzy",
|
|
26
|
+
"fuzzy-finder",
|
|
27
|
+
"grep",
|
|
28
|
+
"finder",
|
|
29
|
+
"neovim",
|
|
30
|
+
"chat-history"
|
|
22
31
|
],
|
|
23
32
|
"scripts": {
|
|
24
33
|
"typecheck": "bun tsc -p tsconfig.json --noEmit",
|
package/search.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite"
|
|
|
2
2
|
import { existsSync, readdirSync } from "node:fs"
|
|
3
3
|
import { homedir } from "node:os"
|
|
4
4
|
import path from "node:path"
|
|
5
|
+
import { debug } from "./ui/debug.ts"
|
|
5
6
|
|
|
6
7
|
export type SearchResult = {
|
|
7
8
|
id: string
|
|
@@ -100,22 +101,22 @@ export function resolveDatabasePath() {
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
export function searchSessionMessages(query: string, options?: { limit?: number; dbPath?: string; directory?: string }) {
|
|
104
|
+
export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
|
|
104
105
|
const term = query.trim()
|
|
105
106
|
if (!term) return []
|
|
106
107
|
if (options?.dbPath === ":memory:") return []
|
|
107
108
|
const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
|
|
108
109
|
try {
|
|
109
|
-
return searchRows(db, term, options?.limit ?? 80, options?.directory)
|
|
110
|
+
return searchRows(db, term, options?.limit ?? 80, options?.directory, options?.offset)
|
|
110
111
|
} finally {
|
|
111
112
|
db.close()
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
export function recentSessionMessages(options?: { limit?: number; dbPath?: string; directory?: string }) {
|
|
116
|
+
export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
|
|
116
117
|
const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
|
|
117
118
|
try {
|
|
118
|
-
return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory).flatMap(
|
|
119
|
+
return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset).flatMap(
|
|
119
120
|
(row) => rowToSearchResult(row, "") ?? [],
|
|
120
121
|
)
|
|
121
122
|
} finally {
|
|
@@ -124,34 +125,67 @@ export function recentSessionMessages(options?: { limit?: number; dbPath?: strin
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
export function loadConversationWindow(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }) {
|
|
128
|
+
debug.time("preview:query:total")
|
|
127
129
|
const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
|
|
128
130
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
131
|
+
const before = options?.before ?? 3
|
|
132
|
+
const after = options?.after ?? 6
|
|
133
|
+
|
|
134
|
+
debug.time("preview:query:hit")
|
|
135
|
+
const hit = db.query<{ time_created: number }, [string]>(
|
|
136
|
+
"SELECT time_created FROM part WHERE id = ?",
|
|
137
|
+
).get(result.id)
|
|
138
|
+
debug.timeEnd("preview:query:hit")
|
|
139
|
+
if (!hit) {
|
|
140
|
+
debug.timeEnd("preview:query:total")
|
|
141
|
+
return []
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const fetchBefore = Math.max(before * 4, 30)
|
|
145
|
+
const fetchAfter = Math.max(after * 4, 50)
|
|
146
|
+
|
|
147
|
+
debug.time("preview:query:before")
|
|
148
|
+
const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
|
|
149
|
+
SELECT p.id, p.message_id, p.session_id,
|
|
150
|
+
json_extract(m.data, '$.role') AS role,
|
|
151
|
+
json_extract(p.data, '$.type') AS type,
|
|
152
|
+
p.time_created, p.data
|
|
153
|
+
FROM part p
|
|
154
|
+
JOIN message m ON m.id = p.message_id
|
|
155
|
+
WHERE p.session_id = ?
|
|
156
|
+
AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
|
|
157
|
+
ORDER BY p.time_created DESC, p.id DESC
|
|
158
|
+
LIMIT ?
|
|
159
|
+
`).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchBefore)
|
|
160
|
+
debug.timeEnd("preview:query:before")
|
|
161
|
+
|
|
162
|
+
debug.time("preview:query:after")
|
|
163
|
+
const afterRows = db.query<ConversationRow, [string, number, number, string, number]>(`
|
|
164
|
+
SELECT p.id, p.message_id, p.session_id,
|
|
165
|
+
json_extract(m.data, '$.role') AS role,
|
|
166
|
+
json_extract(p.data, '$.type') AS type,
|
|
167
|
+
p.time_created, p.data
|
|
168
|
+
FROM part p
|
|
169
|
+
JOIN message m ON m.id = p.message_id
|
|
170
|
+
WHERE p.session_id = ?
|
|
171
|
+
AND (p.time_created > ? OR (p.time_created = ? AND p.id >= ?))
|
|
172
|
+
ORDER BY p.time_created ASC, p.id ASC
|
|
173
|
+
LIMIT ?
|
|
174
|
+
`).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchAfter)
|
|
175
|
+
debug.timeEnd("preview:query:after")
|
|
176
|
+
|
|
177
|
+
debug.time("preview:query:parse")
|
|
178
|
+
const isValid = (row: ConversationRow) =>
|
|
179
|
+
(row.role === "user" || row.role === "assistant") &&
|
|
180
|
+
(row.type === "text" || row.type === "reasoning" || row.type === "tool")
|
|
181
|
+
|
|
182
|
+
const parts = [
|
|
183
|
+
...beforeRows.filter(isValid).slice(0, before).reverse(),
|
|
184
|
+
...afterRows.filter(isValid).slice(0, after + 1),
|
|
185
|
+
].flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
|
|
186
|
+
debug.timeEnd("preview:query:parse")
|
|
187
|
+
debug.timeEnd("preview:query:total")
|
|
188
|
+
return parts
|
|
155
189
|
} finally {
|
|
156
190
|
db.close()
|
|
157
191
|
}
|
|
@@ -196,10 +230,20 @@ export function extractSearchText(data: string) {
|
|
|
196
230
|
|
|
197
231
|
export function makeSnippet(text: string, query: string, radius = 72) {
|
|
198
232
|
const haystack = text.replace(/\s+/g, " ").trim()
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
233
|
+
const tokens = query.trim().split(/\s+/)
|
|
234
|
+
const lower = haystack.toLowerCase()
|
|
235
|
+
let searchPos = 0
|
|
236
|
+
let firstIndex = -1
|
|
237
|
+
let lastEnd = 0
|
|
238
|
+
for (const token of tokens) {
|
|
239
|
+
const index = lower.indexOf(token.toLowerCase(), searchPos)
|
|
240
|
+
if (index === -1) return truncate(haystack, radius * 2)
|
|
241
|
+
if (firstIndex === -1) firstIndex = index
|
|
242
|
+
searchPos = index + token.length
|
|
243
|
+
lastEnd = searchPos
|
|
244
|
+
}
|
|
245
|
+
const start = Math.max(0, firstIndex - radius)
|
|
246
|
+
const end = Math.min(haystack.length, lastEnd + radius)
|
|
203
247
|
return `${start > 0 ? "..." : ""}${haystack.slice(start, end)}${end < haystack.length ? "..." : ""}`
|
|
204
248
|
}
|
|
205
249
|
|
|
@@ -217,15 +261,27 @@ function findMatch(text: string, query: string, radius = 96) {
|
|
|
217
261
|
excerpt: collapsed.slice(0, end),
|
|
218
262
|
}
|
|
219
263
|
}
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
264
|
+
const tokens = needle.split(/\s+/)
|
|
265
|
+
const lowerText = text.toLowerCase()
|
|
266
|
+
let searchPos = 0
|
|
267
|
+
let firstStart = -1
|
|
268
|
+
let lastEnd = -1
|
|
269
|
+
for (const token of tokens) {
|
|
270
|
+
const pos = lowerText.indexOf(token.toLowerCase(), searchPos)
|
|
271
|
+
if (pos === -1) return
|
|
272
|
+
if (firstStart === -1) firstStart = pos
|
|
273
|
+
searchPos = pos + token.length
|
|
274
|
+
lastEnd = searchPos
|
|
275
|
+
}
|
|
276
|
+
const start = firstStart
|
|
277
|
+
const end = lastEnd
|
|
223
278
|
const lineStart = text.lastIndexOf("\n", start - 1) + 1
|
|
224
279
|
const nextLine = text.indexOf("\n", end)
|
|
225
280
|
const lineEnd = nextLine === -1 ? text.length : nextLine
|
|
226
281
|
const line = text.slice(lineStart, lineEnd)
|
|
282
|
+
const matchLen = end - start
|
|
227
283
|
const lineMatchStart = Math.max(0, start - lineStart)
|
|
228
|
-
const lineMatchEnd = lineMatchStart +
|
|
284
|
+
const lineMatchEnd = lineMatchStart + matchLen
|
|
229
285
|
const excerptStart = Math.max(0, lineMatchStart - radius)
|
|
230
286
|
const excerptEnd = Math.min(line.length, lineMatchEnd + radius)
|
|
231
287
|
const before = normalizeSnippetSegment(line.slice(excerptStart, lineMatchStart))
|
|
@@ -361,35 +417,35 @@ function parseToolState(value: unknown): ToolState | undefined {
|
|
|
361
417
|
}
|
|
362
418
|
}
|
|
363
419
|
|
|
364
|
-
function searchRows(db: Database, query: string, limit: number, directory?: string) {
|
|
420
|
+
function searchRows(db: Database, query: string, limit: number, directory?: string, offset?: number) {
|
|
365
421
|
if (!tableExists(db, "part") || !tableExists(db, "message")) return []
|
|
366
|
-
return visibleTextRows(db, limit, query, directory).flatMap((row) => rowToSearchResult(row, query) ?? [])
|
|
422
|
+
return visibleTextRows(db, limit, query, directory, offset).flatMap((row) => rowToSearchResult(row, query) ?? [])
|
|
367
423
|
}
|
|
368
424
|
|
|
369
|
-
function visibleTextRows(db: Database, limit: number, query?: string, directory?: string) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
JOIN session s ON s.id = p.session_id
|
|
381
|
-
WHERE json_extract(p.data, '$.type') = 'text'
|
|
382
|
-
AND json_extract(m.data, '$.role') IN ('user', 'assistant')
|
|
383
|
-
${directory ? "AND s.directory = ?" : ""}
|
|
384
|
-
AND json_extract(p.data, '$.text') LIKE ?
|
|
385
|
-
ORDER BY p.time_created DESC
|
|
386
|
-
LIMIT ?
|
|
387
|
-
`)
|
|
388
|
-
.all(...input)
|
|
425
|
+
function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number) {
|
|
426
|
+
const offsetClause = offset ? "OFFSET ?" : ""
|
|
427
|
+
const conditions: string[] = [
|
|
428
|
+
"json_extract(p.data, '$.type') = 'text'",
|
|
429
|
+
"json_extract(m.data, '$.role') IN ('user', 'assistant')",
|
|
430
|
+
]
|
|
431
|
+
const params: (string | number)[] = []
|
|
432
|
+
|
|
433
|
+
if (directory) {
|
|
434
|
+
conditions.push("s.directory = ?")
|
|
435
|
+
params.push(directory)
|
|
389
436
|
}
|
|
390
|
-
|
|
437
|
+
|
|
438
|
+
const tokens = query ? query.trim().split(/\s+/).filter(Boolean) : []
|
|
439
|
+
for (const token of tokens) {
|
|
440
|
+
conditions.push("json_extract(p.data, '$.text') LIKE ?")
|
|
441
|
+
params.push(`%${token}%`)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
params.push(limit)
|
|
445
|
+
if (offset) params.push(offset)
|
|
446
|
+
|
|
391
447
|
return db
|
|
392
|
-
.query<Row,
|
|
448
|
+
.query<Row, (string | number)[]>(`
|
|
393
449
|
SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
|
|
394
450
|
json_extract(m.data, '$.role') AS role,
|
|
395
451
|
p.time_created,
|
|
@@ -397,13 +453,11 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
|
|
|
397
453
|
FROM part p
|
|
398
454
|
JOIN message m ON m.id = p.message_id
|
|
399
455
|
JOIN session s ON s.id = p.session_id
|
|
400
|
-
WHERE
|
|
401
|
-
AND json_extract(m.data, '$.role') IN ('user', 'assistant')
|
|
402
|
-
${directory ? "AND s.directory = ?" : ""}
|
|
456
|
+
WHERE ${conditions.join(" AND ")}
|
|
403
457
|
ORDER BY p.time_created DESC
|
|
404
|
-
LIMIT ?
|
|
458
|
+
LIMIT ? ${offsetClause}
|
|
405
459
|
`)
|
|
406
|
-
.all(...
|
|
460
|
+
.all(...params as any[])
|
|
407
461
|
}
|
|
408
462
|
|
|
409
463
|
function tableExists(db: Database, name: string) {
|
package/telescope.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ConversationPreviewPart,
|
|
14
14
|
type SearchResult,
|
|
15
15
|
} from "./search.ts"
|
|
16
|
+
import { debug } from "./ui/debug.ts"
|
|
16
17
|
import { syntaxStyle } from "./ui/format.ts"
|
|
17
18
|
import { isKey, prevent } from "./ui/keyboard.ts"
|
|
18
19
|
import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
|
|
@@ -27,6 +28,14 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
27
28
|
const [error, setError] = createSignal("")
|
|
28
29
|
const [mode, setMode] = createSignal<"normal" | "insert">("normal")
|
|
29
30
|
const [loading, setLoading] = createSignal(true)
|
|
31
|
+
const [hasMore, setHasMore] = createSignal(true)
|
|
32
|
+
const [loadingMore, setLoadingMore] = createSignal(false)
|
|
33
|
+
const BATCH_SIZE = 25
|
|
34
|
+
const RECENT_BATCH_SIZE = 15
|
|
35
|
+
const INITIAL_PREVIEW_BEFORE = 3
|
|
36
|
+
const INITIAL_PREVIEW_AFTER = 6
|
|
37
|
+
const PREVIEW_EXPAND_STEP = 5
|
|
38
|
+
const [previewRange, setPreviewRange] = createSignal({ before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
|
|
30
39
|
let input: InputRenderable | undefined
|
|
31
40
|
let resultScroll: ScrollBoxRenderable | undefined
|
|
32
41
|
let previewScroll: ScrollBoxRenderable | undefined
|
|
@@ -44,31 +53,44 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
44
53
|
createEffect(() => {
|
|
45
54
|
const q = query().trim()
|
|
46
55
|
setError("")
|
|
56
|
+
setHasMore(true)
|
|
57
|
+
const db = dbPath()
|
|
58
|
+
const dir = directory
|
|
59
|
+
|
|
47
60
|
if (!q) {
|
|
48
61
|
setLoading(true)
|
|
49
62
|
const timer = setTimeout(() => {
|
|
63
|
+
debug.time("query:recent")
|
|
50
64
|
try {
|
|
51
|
-
|
|
65
|
+
const batch = recentSessionMessages({ limit: RECENT_BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
|
|
66
|
+
setResults(batch)
|
|
67
|
+
setHasMore(batch.length >= RECENT_BATCH_SIZE)
|
|
68
|
+
setSelected(0)
|
|
52
69
|
} catch (err) {
|
|
53
70
|
setResults([])
|
|
54
71
|
setError(err instanceof Error ? err.message : String(err))
|
|
55
72
|
}
|
|
73
|
+
debug.timeEnd("query:recent")
|
|
56
74
|
setLoading(false)
|
|
57
|
-
setSelected(0)
|
|
58
75
|
}, 1)
|
|
59
76
|
onCleanup(() => clearTimeout(timer))
|
|
60
77
|
return
|
|
61
78
|
}
|
|
79
|
+
|
|
62
80
|
setLoading(true)
|
|
63
81
|
setBusy(true)
|
|
64
82
|
const timer = setTimeout(() => {
|
|
83
|
+
debug.time("query:search")
|
|
65
84
|
try {
|
|
66
|
-
|
|
85
|
+
const batch = searchSessionMessages(q, { limit: BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
|
|
86
|
+
setResults(batch)
|
|
87
|
+
setHasMore(batch.length >= BATCH_SIZE)
|
|
67
88
|
setSelected(0)
|
|
68
89
|
} catch (err) {
|
|
69
90
|
setResults([])
|
|
70
91
|
setError(err instanceof Error ? err.message : String(err))
|
|
71
92
|
} finally {
|
|
93
|
+
debug.timeEnd("query:search")
|
|
72
94
|
setBusy(false)
|
|
73
95
|
setLoading(false)
|
|
74
96
|
}
|
|
@@ -78,6 +100,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
78
100
|
|
|
79
101
|
const move = (delta: number) => {
|
|
80
102
|
if (results().length === 0) return
|
|
103
|
+
debug.time("nav:total")
|
|
81
104
|
setSelected((index) => (index + delta + results().length) % results().length)
|
|
82
105
|
}
|
|
83
106
|
|
|
@@ -95,24 +118,82 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
95
118
|
}
|
|
96
119
|
})
|
|
97
120
|
|
|
121
|
+
createEffect(() => {
|
|
122
|
+
const index = selected()
|
|
123
|
+
const total = results().length
|
|
124
|
+
if (index < total - 3) return
|
|
125
|
+
if (!hasMore() || loadingMore() || busy() || loading()) return
|
|
126
|
+
|
|
127
|
+
const q = query().trim()
|
|
128
|
+
const db = dbPath()
|
|
129
|
+
const dir = directory
|
|
130
|
+
|
|
131
|
+
setLoadingMore(true)
|
|
132
|
+
const timer = setTimeout(() => {
|
|
133
|
+
debug.time("query:load-more")
|
|
134
|
+
try {
|
|
135
|
+
const batch = q
|
|
136
|
+
? searchSessionMessages(q, { limit: BATCH_SIZE, offset: total, dbPath: db, directory: dir })
|
|
137
|
+
: recentSessionMessages({ limit: RECENT_BATCH_SIZE, offset: total, dbPath: db, directory: dir })
|
|
138
|
+
setResults((prev) => [...prev, ...batch])
|
|
139
|
+
if (batch.length < (q ? BATCH_SIZE : RECENT_BATCH_SIZE)) setHasMore(false)
|
|
140
|
+
} catch {
|
|
141
|
+
// keep existing results on error
|
|
142
|
+
} finally {
|
|
143
|
+
debug.timeEnd("query:load-more")
|
|
144
|
+
setLoadingMore(false)
|
|
145
|
+
}
|
|
146
|
+
}, 100)
|
|
147
|
+
onCleanup(() => clearTimeout(timer))
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
let lastPreviewItemId = ""
|
|
98
151
|
createEffect(() => {
|
|
99
152
|
const item = selectedResult()
|
|
153
|
+
const range = previewRange()
|
|
100
154
|
if (!item) {
|
|
101
155
|
setPreviewParts([])
|
|
102
156
|
return
|
|
103
157
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
158
|
+
if (item.id !== lastPreviewItemId) {
|
|
159
|
+
lastPreviewItemId = item.id
|
|
160
|
+
debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
|
|
161
|
+
setPreviewRange({ before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
|
|
162
|
+
return
|
|
108
163
|
}
|
|
164
|
+
const db = dbPath()
|
|
165
|
+
debug.time("preview:load")
|
|
166
|
+
try {
|
|
167
|
+
setPreviewParts(loadConversationWindow(item, { before: range.before, after: range.after, dbPath: db }))
|
|
168
|
+
} catch {}
|
|
169
|
+
debug.timeEnd("nav:total")
|
|
170
|
+
debug.timeEnd("preview:load")
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
createEffect(() => {
|
|
174
|
+
const item = selectedResult()
|
|
175
|
+
if (!item) return
|
|
176
|
+
const interval = setInterval(() => {
|
|
177
|
+
const scroll = previewScroll
|
|
178
|
+
const children = scroll?.getChildren()
|
|
179
|
+
if (!scroll || !children || children.length === 0) return
|
|
180
|
+
const range = previewRange()
|
|
181
|
+
const lastChild = children[children.length - 1] as { y: number; height: number }
|
|
182
|
+
const totalContentHeight = lastChild.y + lastChild.height
|
|
183
|
+
const atTop = scroll.y <= 0 && range.before > 0
|
|
184
|
+
const atBottom = scroll.y + scroll.height >= totalContentHeight - 1
|
|
185
|
+
if (atTop) setPreviewRange((prev) => ({ ...prev, before: prev.before + PREVIEW_EXPAND_STEP }))
|
|
186
|
+
if (atBottom) setPreviewRange((prev) => ({ ...prev, after: prev.after + PREVIEW_EXPAND_STEP }))
|
|
187
|
+
}, 400)
|
|
188
|
+
onCleanup(() => clearInterval(interval))
|
|
109
189
|
})
|
|
110
190
|
|
|
111
191
|
createEffect(() => {
|
|
112
192
|
const item = selectedResult()
|
|
113
193
|
previewParts()
|
|
114
194
|
if (!item) return
|
|
115
|
-
setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
|
|
195
|
+
const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
|
|
196
|
+
onCleanup(() => clearTimeout(timer))
|
|
116
197
|
})
|
|
117
198
|
|
|
118
199
|
const open = () => {
|
|
@@ -225,7 +306,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
225
306
|
}}
|
|
226
307
|
flexGrow={1}
|
|
227
308
|
/>
|
|
228
|
-
<text fg={theme().textMuted}>{busy() ? "searching" : loading() ? "loading..." : query().trim() ? `${results().length} hits` : `${results().length} recent`}</text>
|
|
309
|
+
<text fg={theme().textMuted}>{busy() ? "searching" : loading() ? "loading..." : query().trim() ? (results().length > 0 ? `${selected() + 1}/${results().length} hits` : "0 hits") : (results().length > 0 ? `${selected() + 1}/${results().length} recent` : "0 recent")}</text>
|
|
229
310
|
</box>
|
|
230
311
|
</box>
|
|
231
312
|
|
|
@@ -256,6 +337,12 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
256
337
|
/>
|
|
257
338
|
)}
|
|
258
339
|
</For>
|
|
340
|
+
<Show when={loadingMore()}>
|
|
341
|
+
<SkeletonRow theme={theme()} />
|
|
342
|
+
</Show>
|
|
343
|
+
<Show when={!hasMore() && results().length > 0}>
|
|
344
|
+
<text fg={theme().textMuted}> no more results</text>
|
|
345
|
+
</Show>
|
|
259
346
|
</Show>
|
|
260
347
|
</Show>
|
|
261
348
|
<Show when={loading()}>
|
package/tsconfig.json
CHANGED
package/ui/debug.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const enabled = process.env.OPENCODE_TELESCOPE_DEBUG === "1"
|
|
2
|
+
const timers = new Map<string, number>()
|
|
3
|
+
|
|
4
|
+
export const debug = {
|
|
5
|
+
get enabled() {
|
|
6
|
+
return enabled
|
|
7
|
+
},
|
|
8
|
+
|
|
9
|
+
time(label: string) {
|
|
10
|
+
if (enabled) timers.set(label, performance.now())
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
timeEnd(label: string) {
|
|
14
|
+
if (!enabled) return
|
|
15
|
+
const start = timers.get(label)
|
|
16
|
+
if (start !== undefined) {
|
|
17
|
+
console.log(`[telescope:time] ${label} ${(performance.now() - start).toFixed(2)}ms`)
|
|
18
|
+
timers.delete(label)
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
log(...args: unknown[]) {
|
|
23
|
+
if (enabled) console.log("[telescope]", ...args)
|
|
24
|
+
},
|
|
25
|
+
}
|