@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 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 `Ctrl+j` / `Ctrl+k` |
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() ? "No matching user/assistant conversation text." : "No recent conversation text found."}</text>
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.12",
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: "user" | "assistant"
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: "user" | "assistant"
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 loadConversationWindow(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }) {
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 isValid = (row: ConversationRow) =>
186
- (row.role === "user" || row.role === "assistant") &&
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
- ...beforeRows.filter(isValid).slice(0, before).reverse(),
191
- ...afterRows.filter(isValid).slice(0, after + 1),
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 parts
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)