@bojackduy/opencode-telescope 0.1.12 → 0.1.14
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 +39 -1
- package/components/result-list.tsx +2 -2
- package/package.json +1 -1
- package/search.ts +144 -22
- package/telescope.tsx +545 -127
- package/tui.tsx +4 -2
- package/ui/config.test.ts +48 -0
- package/ui/config.ts +93 -0
- package/ui/debug.ts +31 -2
- package/ui/keyboard.test.ts +40 -0
- package/ui/keyboard.ts +34 -0
package/README.md
CHANGED
|
@@ -42,9 +42,47 @@ Add the plugin to your `tui.json`:
|
|
|
42
42
|
| ---------------- | ----------------------------------------------- |
|
|
43
43
|
| Open search | `<leader>f` or `/telescope` |
|
|
44
44
|
| Type to filter | Fuzzy match against conversation text |
|
|
45
|
-
| Navigate results | `↑` / `↓` or `
|
|
45
|
+
| Navigate results | `↑` / `↓` or `j` / `k` |
|
|
46
46
|
| Preview | Select a result to see the conversation preview |
|
|
47
47
|
| Open | Press `Enter` to jump to the selected session |
|
|
48
|
+
| Owner filter | Press `o` to cycle `all` / `you` / `assistant` |
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
Telescope reads optional plugin-specific config from:
|
|
53
|
+
|
|
54
|
+
```txt
|
|
55
|
+
~/.config/opencode/opencode-telescope/config.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If `$XDG_CONFIG_HOME` is set, the path is:
|
|
59
|
+
|
|
60
|
+
```txt
|
|
61
|
+
$XDG_CONFIG_HOME/opencode/opencode-telescope/config.json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Missing config, invalid JSON, and invalid individual fields are ignored. Defaults are kept for anything not configured.
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
{
|
|
70
|
+
"openKey": "<leader>f",
|
|
71
|
+
"keys": {
|
|
72
|
+
"moveDown": ["down", "j"],
|
|
73
|
+
"moveUp": ["up", "k"],
|
|
74
|
+
"scrollPreviewDown": ["d"],
|
|
75
|
+
"scrollPreviewUp": ["u"],
|
|
76
|
+
"open": ["enter", "return"],
|
|
77
|
+
"close": ["q", "escape"],
|
|
78
|
+
"insertMode": ["/"],
|
|
79
|
+
"normalMode": ["ctrl+q"],
|
|
80
|
+
"toggleOwner": ["o"]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Key strings support simple names like `j`, `k`, `down`, `up`, `enter`, `return`, and `escape`, plus modifier strings like `ctrl+q`.
|
|
48
86
|
|
|
49
87
|
## How it works
|
|
50
88
|
|
|
@@ -54,9 +54,9 @@ export const ResultRow = (props: {
|
|
|
54
54
|
</box>
|
|
55
55
|
)
|
|
56
56
|
|
|
57
|
-
export const EmptyState = (props: { query: string; theme: TuiThemeCurrent }) => (
|
|
57
|
+
export const EmptyState = (props: { query: string; owner: string; theme: TuiThemeCurrent }) => (
|
|
58
58
|
<box paddingLeft={1} paddingTop={1}>
|
|
59
|
-
<text fg={props.theme.textMuted}>{props.query.trim() ?
|
|
59
|
+
<text fg={props.theme.textMuted}>{props.query.trim() ? `No matching ${props.owner} conversation text.` : `No recent ${props.owner} conversation text found.`}</text>
|
|
60
60
|
</box>
|
|
61
61
|
)
|
|
62
62
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-telescope",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.14",
|
|
5
5
|
"description": "Fuzzy search across all OpenCode conversations — grep session and chat history, find code snippets, and jump to any chat instantly",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
package/search.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type SearchResult = {
|
|
|
27
27
|
text: string
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export type SearchRole = "user" | "assistant"
|
|
31
|
+
|
|
30
32
|
export type ConversationPreviewPart = {
|
|
31
33
|
id: string
|
|
32
34
|
messageID: string
|
|
@@ -40,6 +42,17 @@ export type ConversationPreviewPart = {
|
|
|
40
42
|
target: boolean
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
export type ConversationPreviewPage = {
|
|
46
|
+
parts: ConversationPreviewPart[]
|
|
47
|
+
hasMoreBefore: boolean
|
|
48
|
+
hasMoreAfter: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ConversationPreviewCursor = {
|
|
52
|
+
id: string
|
|
53
|
+
timeCreated: number
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
export type ToolState = {
|
|
44
57
|
status: "pending" | "running" | "completed" | "error"
|
|
45
58
|
input?: unknown
|
|
@@ -53,7 +66,7 @@ type Row = {
|
|
|
53
66
|
session_id: string
|
|
54
67
|
session_title: string | null
|
|
55
68
|
directory: string
|
|
56
|
-
role:
|
|
69
|
+
role: SearchRole
|
|
57
70
|
time_created: number
|
|
58
71
|
text: string
|
|
59
72
|
}
|
|
@@ -62,7 +75,7 @@ type ConversationRow = {
|
|
|
62
75
|
id: string
|
|
63
76
|
message_id: string
|
|
64
77
|
session_id: string
|
|
65
|
-
role:
|
|
78
|
+
role: SearchRole
|
|
66
79
|
type: "text" | "reasoning" | "tool"
|
|
67
80
|
time_created: number
|
|
68
81
|
data: string
|
|
@@ -116,23 +129,23 @@ export function resolveDatabasePath() {
|
|
|
116
129
|
}
|
|
117
130
|
}
|
|
118
131
|
|
|
119
|
-
export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
|
|
132
|
+
export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
|
|
120
133
|
const term = query.trim()
|
|
121
134
|
if (!term) return []
|
|
122
135
|
if (options?.dbPath === ":memory:") return []
|
|
123
136
|
const dbPath = options?.dbPath ?? resolveDatabasePath()
|
|
124
137
|
const db = getDb(dbPath)
|
|
125
|
-
return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset)
|
|
138
|
+
return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset, options?.role)
|
|
126
139
|
}
|
|
127
140
|
|
|
128
|
-
export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
|
|
141
|
+
export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
|
|
129
142
|
const db = getDb(options?.dbPath)
|
|
130
|
-
return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset).flatMap(
|
|
143
|
+
return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset, options?.role).flatMap(
|
|
131
144
|
(row) => rowToSearchResult(row, "") ?? [],
|
|
132
145
|
)
|
|
133
146
|
}
|
|
134
147
|
|
|
135
|
-
export function
|
|
148
|
+
export function loadConversationAround(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }): ConversationPreviewPage {
|
|
136
149
|
debug.time("preview:query:total")
|
|
137
150
|
const db = getDb(options?.dbPath)
|
|
138
151
|
const before = options?.before ?? 3
|
|
@@ -144,12 +157,19 @@ export function loadConversationWindow(result: SearchResult, options?: { before?
|
|
|
144
157
|
).get(result.id)
|
|
145
158
|
debug.timeEnd("preview:query:hit")
|
|
146
159
|
if (!hit) {
|
|
160
|
+
debug.log("preview:window", {
|
|
161
|
+
item: result.id,
|
|
162
|
+
session: result.sessionID,
|
|
163
|
+
before,
|
|
164
|
+
after,
|
|
165
|
+
hit: false,
|
|
166
|
+
})
|
|
147
167
|
debug.timeEnd("preview:query:total")
|
|
148
|
-
return []
|
|
168
|
+
return { parts: [], hasMoreBefore: false, hasMoreAfter: false }
|
|
149
169
|
}
|
|
150
170
|
|
|
151
|
-
const fetchBefore = Math.max(before * 4, 30)
|
|
152
|
-
const fetchAfter = Math.max(after * 4, 50)
|
|
171
|
+
const fetchBefore = Math.max(before * 4, before + 1, 30)
|
|
172
|
+
const fetchAfter = Math.max((after + 1) * 4, after + 2, 50)
|
|
153
173
|
|
|
154
174
|
debug.time("preview:query:before")
|
|
155
175
|
const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
|
|
@@ -182,17 +202,108 @@ export function loadConversationWindow(result: SearchResult, options?: { before?
|
|
|
182
202
|
debug.timeEnd("preview:query:after")
|
|
183
203
|
|
|
184
204
|
debug.time("preview:query:parse")
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
(row.type === "text" || row.type === "reasoning" || row.type === "tool")
|
|
188
|
-
|
|
205
|
+
const validBefore = beforeRows.filter(isPreviewRow)
|
|
206
|
+
const validAfter = afterRows.filter(isPreviewRow)
|
|
189
207
|
const parts = [
|
|
190
|
-
...
|
|
191
|
-
...
|
|
208
|
+
...validBefore.slice(0, before).reverse(),
|
|
209
|
+
...validAfter.slice(0, after + 1),
|
|
192
210
|
].flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
|
|
211
|
+
const page = {
|
|
212
|
+
parts,
|
|
213
|
+
hasMoreBefore: validBefore.length > before,
|
|
214
|
+
hasMoreAfter: validAfter.length > after + 1,
|
|
215
|
+
}
|
|
216
|
+
debug.log("preview:window", {
|
|
217
|
+
item: result.id,
|
|
218
|
+
session: result.sessionID,
|
|
219
|
+
mode: "around",
|
|
220
|
+
before,
|
|
221
|
+
after,
|
|
222
|
+
fetchBefore,
|
|
223
|
+
fetchAfter,
|
|
224
|
+
beforeRows: beforeRows.length,
|
|
225
|
+
afterRows: afterRows.length,
|
|
226
|
+
parts: parts.length,
|
|
227
|
+
hasMoreBefore: page.hasMoreBefore,
|
|
228
|
+
hasMoreAfter: page.hasMoreAfter,
|
|
229
|
+
first: parts[0]?.id,
|
|
230
|
+
last: parts.at(-1)?.id,
|
|
231
|
+
})
|
|
193
232
|
debug.timeEnd("preview:query:parse")
|
|
194
233
|
debug.timeEnd("preview:query:total")
|
|
195
|
-
return
|
|
234
|
+
return page
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function loadConversationBefore(result: SearchResult, cursor: ConversationPreviewCursor, options?: { limit?: number; dbPath?: string }) {
|
|
238
|
+
debug.time("preview:query:before-page")
|
|
239
|
+
const db = getDb(options?.dbPath)
|
|
240
|
+
const limit = options?.limit ?? 20
|
|
241
|
+
const fetchLimit = Math.max(limit * 4, limit + 1, 30)
|
|
242
|
+
const rows = db.query<ConversationRow, [string, number, number, string, number]>(`
|
|
243
|
+
SELECT p.id, p.message_id, p.session_id,
|
|
244
|
+
json_extract(m.data, '$.role') AS role,
|
|
245
|
+
json_extract(p.data, '$.type') AS type,
|
|
246
|
+
p.time_created, p.data
|
|
247
|
+
FROM part p
|
|
248
|
+
JOIN message m ON m.id = p.message_id
|
|
249
|
+
WHERE p.session_id = ?
|
|
250
|
+
AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
|
|
251
|
+
ORDER BY p.time_created DESC, p.id DESC
|
|
252
|
+
LIMIT ?
|
|
253
|
+
`).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
|
|
254
|
+
const valid = rows.filter(isPreviewRow)
|
|
255
|
+
const parts = valid.slice(0, limit).reverse().flatMap((row) => parseConversationPart(row, false) ?? [])
|
|
256
|
+
const page = { parts, hasMoreBefore: valid.length > limit }
|
|
257
|
+
debug.log("preview:window", {
|
|
258
|
+
item: result.id,
|
|
259
|
+
session: result.sessionID,
|
|
260
|
+
mode: "before",
|
|
261
|
+
cursor,
|
|
262
|
+
limit,
|
|
263
|
+
rows: rows.length,
|
|
264
|
+
parts: parts.length,
|
|
265
|
+
hasMoreBefore: page.hasMoreBefore,
|
|
266
|
+
first: parts[0]?.id,
|
|
267
|
+
last: parts.at(-1)?.id,
|
|
268
|
+
})
|
|
269
|
+
debug.timeEnd("preview:query:before-page")
|
|
270
|
+
return page
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function loadConversationAfter(result: SearchResult, cursor: ConversationPreviewCursor, options?: { limit?: number; dbPath?: string }) {
|
|
274
|
+
debug.time("preview:query:after-page")
|
|
275
|
+
const db = getDb(options?.dbPath)
|
|
276
|
+
const limit = options?.limit ?? 20
|
|
277
|
+
const fetchLimit = Math.max(limit * 4, limit + 1, 30)
|
|
278
|
+
const rows = db.query<ConversationRow, [string, number, number, string, number]>(`
|
|
279
|
+
SELECT p.id, p.message_id, p.session_id,
|
|
280
|
+
json_extract(m.data, '$.role') AS role,
|
|
281
|
+
json_extract(p.data, '$.type') AS type,
|
|
282
|
+
p.time_created, p.data
|
|
283
|
+
FROM part p
|
|
284
|
+
JOIN message m ON m.id = p.message_id
|
|
285
|
+
WHERE p.session_id = ?
|
|
286
|
+
AND (p.time_created > ? OR (p.time_created = ? AND p.id > ?))
|
|
287
|
+
ORDER BY p.time_created ASC, p.id ASC
|
|
288
|
+
LIMIT ?
|
|
289
|
+
`).all(result.sessionID, cursor.timeCreated, cursor.timeCreated, cursor.id, fetchLimit)
|
|
290
|
+
const valid = rows.filter(isPreviewRow)
|
|
291
|
+
const parts = valid.slice(0, limit).flatMap((row) => parseConversationPart(row, false) ?? [])
|
|
292
|
+
const page = { parts, hasMoreAfter: valid.length > limit }
|
|
293
|
+
debug.log("preview:window", {
|
|
294
|
+
item: result.id,
|
|
295
|
+
session: result.sessionID,
|
|
296
|
+
mode: "after",
|
|
297
|
+
cursor,
|
|
298
|
+
limit,
|
|
299
|
+
rows: rows.length,
|
|
300
|
+
parts: parts.length,
|
|
301
|
+
hasMoreAfter: page.hasMoreAfter,
|
|
302
|
+
first: parts[0]?.id,
|
|
303
|
+
last: parts.at(-1)?.id,
|
|
304
|
+
})
|
|
305
|
+
debug.timeEnd("preview:query:after-page")
|
|
306
|
+
return page
|
|
196
307
|
}
|
|
197
308
|
|
|
198
309
|
export function rowToSearchResult(row: Row, query: string): SearchResult | undefined {
|
|
@@ -399,6 +510,11 @@ function parseConversationPart(row: ConversationRow, target: boolean): Conversat
|
|
|
399
510
|
}
|
|
400
511
|
}
|
|
401
512
|
|
|
513
|
+
function isPreviewRow(row: ConversationRow) {
|
|
514
|
+
return (row.role === "user" || row.role === "assistant") &&
|
|
515
|
+
(row.type === "text" || row.type === "reasoning" || row.type === "tool")
|
|
516
|
+
}
|
|
517
|
+
|
|
402
518
|
function parsePartData(data: string) {
|
|
403
519
|
try {
|
|
404
520
|
const value = JSON.parse(data) as unknown
|
|
@@ -421,10 +537,10 @@ function parseToolState(value: unknown): ToolState | undefined {
|
|
|
421
537
|
}
|
|
422
538
|
}
|
|
423
539
|
|
|
424
|
-
function searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number) {
|
|
540
|
+
function searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
|
|
425
541
|
if (!tableExists(db, "part") || !tableExists(db, "message")) return []
|
|
426
542
|
debug.time("query:sql")
|
|
427
|
-
const rows = indexedTextRows(db, dbPath, limit, query, directory, offset) ?? visibleTextRows(db, limit, query, directory, offset)
|
|
543
|
+
const rows = indexedTextRows(db, dbPath, limit, query, directory, offset, role) ?? visibleTextRows(db, limit, query, directory, offset, role)
|
|
428
544
|
debug.timeEnd("query:sql")
|
|
429
545
|
debug.time("query:map")
|
|
430
546
|
const results = rows.flatMap((row) => rowToSearchResult(row, query) ?? [])
|
|
@@ -432,13 +548,17 @@ function searchRows(db: Database, dbPath: string, query: string, limit: number,
|
|
|
432
548
|
return results
|
|
433
549
|
}
|
|
434
550
|
|
|
435
|
-
function indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number) {
|
|
551
|
+
function indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number, role?: SearchRole) {
|
|
436
552
|
const match = ftsQuery(query)
|
|
437
553
|
if (!match) return []
|
|
438
554
|
try {
|
|
439
555
|
const index = ensureSearchIndex(db, dbPath)
|
|
440
556
|
const conditions = ["document_fts MATCH ?"]
|
|
441
557
|
const params: (string | number)[] = [match]
|
|
558
|
+
if (role) {
|
|
559
|
+
conditions.push("role = ?")
|
|
560
|
+
params.push(role)
|
|
561
|
+
}
|
|
442
562
|
if (directory) {
|
|
443
563
|
conditions.push("directory = ?")
|
|
444
564
|
params.push(directory)
|
|
@@ -463,14 +583,16 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
|
|
|
463
583
|
}
|
|
464
584
|
}
|
|
465
585
|
|
|
466
|
-
function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number) {
|
|
586
|
+
function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
|
|
467
587
|
const offsetClause = offset ? "OFFSET ?" : ""
|
|
468
588
|
const conditions: string[] = [
|
|
469
589
|
"json_extract(p.data, '$.type') = 'text'",
|
|
470
|
-
"json_extract(m.data, '$.role') IN ('user', 'assistant')",
|
|
590
|
+
role ? "json_extract(m.data, '$.role') = ?" : "json_extract(m.data, '$.role') IN ('user', 'assistant')",
|
|
471
591
|
]
|
|
472
592
|
const params: (string | number)[] = []
|
|
473
593
|
|
|
594
|
+
if (role) params.push(role)
|
|
595
|
+
|
|
474
596
|
if (directory) {
|
|
475
597
|
conditions.push("s.directory = ?")
|
|
476
598
|
params.push(directory)
|