@bojackduy/opencode-telescope 0.1.13 → 0.1.15

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.13",
4
+ "version": "0.1.15",
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
@@ -11,6 +11,7 @@ export type SearchResult = {
11
11
  sessionTitle: string
12
12
  directory: string
13
13
  role: "user" | "assistant"
14
+ partType: "text" | "reasoning" | "tool" | "patch"
14
15
  timeCreated: number
15
16
  snippet: string
16
17
  matchStart: number
@@ -27,6 +28,8 @@ export type SearchResult = {
27
28
  text: string
28
29
  }
29
30
 
31
+ export type SearchRole = "user" | "assistant"
32
+
30
33
  export type ConversationPreviewPart = {
31
34
  id: string
32
35
  messageID: string
@@ -64,16 +67,21 @@ type Row = {
64
67
  session_id: string
65
68
  session_title: string | null
66
69
  directory: string
67
- role: "user" | "assistant"
70
+ role: SearchRole
71
+ part_type?: SearchResult["partType"]
68
72
  time_created: number
69
73
  text: string
70
74
  }
71
75
 
76
+ type SourceRow = Omit<Row, "text"> & {
77
+ data: string
78
+ }
79
+
72
80
  type ConversationRow = {
73
81
  id: string
74
82
  message_id: string
75
83
  session_id: string
76
- role: "user" | "assistant"
84
+ role: SearchRole
77
85
  type: "text" | "reasoning" | "tool"
78
86
  time_created: number
79
87
  data: string
@@ -127,18 +135,18 @@ export function resolveDatabasePath() {
127
135
  }
128
136
  }
129
137
 
130
- export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
138
+ export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
131
139
  const term = query.trim()
132
140
  if (!term) return []
133
141
  if (options?.dbPath === ":memory:") return []
134
142
  const dbPath = options?.dbPath ?? resolveDatabasePath()
135
143
  const db = getDb(dbPath)
136
- return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset)
144
+ return searchRows(db, dbPath, term, options?.limit ?? 80, options?.directory, options?.offset, options?.role)
137
145
  }
138
146
 
139
- export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
147
+ export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
140
148
  const db = getDb(options?.dbPath)
141
- return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset).flatMap(
149
+ return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset, options?.role).flatMap(
142
150
  (row) => rowToSearchResult(row, "") ?? [],
143
151
  )
144
152
  }
@@ -316,6 +324,7 @@ export function rowToSearchResult(row: Row, query: string): SearchResult | undef
316
324
  sessionTitle: row.session_title || "Untitled session",
317
325
  directory: row.directory,
318
326
  role: row.role,
327
+ partType: row.part_type ?? "text",
319
328
  timeCreated: row.time_created,
320
329
  snippet: makeSnippet(text, query),
321
330
  matchStart: match.start,
@@ -335,7 +344,7 @@ export function rowToSearchResult(row: Row, query: string): SearchResult | undef
335
344
 
336
345
  export function extractSearchText(data: string) {
337
346
  try {
338
- return extractFromValue(JSON.parse(data)).replace(/\s+/g, " ").trim()
347
+ return searchablePartText(JSON.parse(data)).replace(/\s+/g, " ").trim()
339
348
  } catch {
340
349
  return data.replace(/\s+/g, " ").trim()
341
350
  }
@@ -535,10 +544,10 @@ function parseToolState(value: unknown): ToolState | undefined {
535
544
  }
536
545
  }
537
546
 
538
- function searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number) {
547
+ function searchRows(db: Database, dbPath: string, query: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
539
548
  if (!tableExists(db, "part") || !tableExists(db, "message")) return []
540
549
  debug.time("query:sql")
541
- const rows = indexedTextRows(db, dbPath, limit, query, directory, offset) ?? visibleTextRows(db, limit, query, directory, offset)
550
+ const rows = indexedTextRows(db, dbPath, limit, query, directory, offset, role) ?? visibleTextRows(db, limit, query, directory, offset, role)
542
551
  debug.timeEnd("query:sql")
543
552
  debug.time("query:map")
544
553
  const results = rows.flatMap((row) => rowToSearchResult(row, query) ?? [])
@@ -546,13 +555,17 @@ function searchRows(db: Database, dbPath: string, query: string, limit: number,
546
555
  return results
547
556
  }
548
557
 
549
- function indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number) {
558
+ function indexedTextRows(db: Database, dbPath: string, limit: number, query: string, directory?: string, offset?: number, role?: SearchRole) {
550
559
  const match = ftsQuery(query)
551
560
  if (!match) return []
552
561
  try {
553
562
  const index = ensureSearchIndex(db, dbPath)
554
563
  const conditions = ["document_fts MATCH ?"]
555
564
  const params: (string | number)[] = [match]
565
+ if (role) {
566
+ conditions.push("role = ?")
567
+ params.push(role)
568
+ }
556
569
  if (directory) {
557
570
  conditions.push("directory = ?")
558
571
  params.push(directory)
@@ -563,7 +576,7 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
563
576
  debug.time("query:fts:exec")
564
577
  const rows = index.query<Row, (string | number)[]>(`
565
578
  SELECT id, message_id, session_id, session_title, directory, role,
566
- CAST(time_created AS INTEGER) AS time_created, text
579
+ part_type, CAST(time_created AS INTEGER) AS time_created, text
567
580
  FROM document_fts
568
581
  WHERE ${conditions.join(" AND ")}
569
582
  ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
@@ -577,14 +590,16 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
577
590
  }
578
591
  }
579
592
 
580
- function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number) {
593
+ function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
581
594
  const offsetClause = offset ? "OFFSET ?" : ""
582
595
  const conditions: string[] = [
583
- "json_extract(p.data, '$.type') = 'text'",
584
- "json_extract(m.data, '$.role') IN ('user', 'assistant')",
596
+ "json_extract(p.data, '$.type') IN ('text', 'reasoning', 'tool', 'patch')",
597
+ role ? "json_extract(m.data, '$.role') = ?" : "json_extract(m.data, '$.role') IN ('user', 'assistant')",
585
598
  ]
586
599
  const params: (string | number)[] = []
587
600
 
601
+ if (role) params.push(role)
602
+
588
603
  if (directory) {
589
604
  conditions.push("s.directory = ?")
590
605
  params.push(directory)
@@ -592,7 +607,7 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
592
607
 
593
608
  const tokens = query ? query.trim().split(/\s+/).filter(Boolean) : []
594
609
  for (const token of tokens) {
595
- conditions.push("json_extract(p.data, '$.text') LIKE ?")
610
+ conditions.push("p.data LIKE ?")
596
611
  params.push(`%${token}%`)
597
612
  }
598
613
 
@@ -602,8 +617,9 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
602
617
  const sql = `
603
618
  SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
604
619
  json_extract(m.data, '$.role') AS role,
620
+ json_extract(p.data, '$.type') AS part_type,
605
621
  p.time_created,
606
- json_extract(p.data, '$.text') AS text
622
+ p.data
607
623
  FROM part p
608
624
  JOIN message m ON m.id = p.message_id
609
625
  JOIN session s ON s.id = p.session_id
@@ -612,9 +628,9 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
612
628
  LIMIT ? ${offsetClause}
613
629
  `
614
630
  debug.time("query:sql:exec")
615
- const rows = db.query<Row, (string | number)[]>(sql).all(...params as any[])
631
+ const rows = db.query<SourceRow, (string | number)[]>(sql).all(...params as any[])
616
632
  debug.timeEnd("query:sql:exec")
617
- return rows
633
+ return rows.flatMap(sourceRowToSearchRow)
618
634
  }
619
635
 
620
636
  function ensureSearchIndex(source: Database, sourcePath: string) {
@@ -630,12 +646,15 @@ function ensureSearchIndex(source: Database, sourcePath: string) {
630
646
  const currentDataVersion = getMeta(_indexDb, "source_data_version")
631
647
  const currentMtimeMs = getMeta(_indexDb, "source_mtime_ms")
632
648
  const currentPath = getMeta(_indexDb, "source_path")
633
- if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs)) {
649
+ const currentIndexVersion = getMeta(_indexDb, "index_version")
650
+ if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
634
651
  rebuildSearchIndex(source, _indexDb, sourcePath, state)
635
652
  }
636
653
  return _indexDb
637
654
  }
638
655
 
656
+ const SEARCH_INDEX_VERSION = "3"
657
+
639
658
  function searchIndexPath(sourcePath: string) {
640
659
  const parsed = path.parse(sourcePath)
641
660
  return path.join(parsed.dir, `${parsed.name}-search.db`)
@@ -654,11 +673,31 @@ function migrateSearchIndex(db: Database) {
654
673
  session_title,
655
674
  directory UNINDEXED,
656
675
  role UNINDEXED,
676
+ part_type UNINDEXED,
657
677
  time_created UNINDEXED,
658
678
  text,
659
679
  tokenize='unicode61'
660
680
  );
661
681
  `)
682
+
683
+ const hasPartType = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().some((column) => column.name === "part_type")
684
+ if (!hasPartType) {
685
+ db.exec("DROP TABLE document_fts")
686
+ db.exec(`
687
+ CREATE VIRTUAL TABLE document_fts USING fts5(
688
+ id UNINDEXED,
689
+ message_id UNINDEXED,
690
+ session_id UNINDEXED,
691
+ session_title,
692
+ directory UNINDEXED,
693
+ role UNINDEXED,
694
+ part_type UNINDEXED,
695
+ time_created UNINDEXED,
696
+ text,
697
+ tokenize='unicode61'
698
+ );
699
+ `)
700
+ }
662
701
  }
663
702
 
664
703
  function sourceState(db: Database, sourcePath: string) {
@@ -669,27 +708,27 @@ function sourceState(db: Database, sourcePath: string) {
669
708
 
670
709
  function rebuildSearchIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
671
710
  debug.time("fts:rebuild")
672
- const rows = source.query<Row, []>(`
711
+ const rows = source.query<SourceRow, []>(`
673
712
  SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
674
- json_extract(m.data, '$.role') AS role,
675
- p.time_created,
676
- json_extract(p.data, '$.text') AS text
713
+ json_extract(m.data, '$.role') AS role,
714
+ json_extract(p.data, '$.type') AS part_type,
715
+ p.time_created,
716
+ p.data
677
717
  FROM part p
678
718
  JOIN message m ON m.id = p.message_id
679
719
  JOIN session s ON s.id = p.session_id
680
- WHERE json_extract(p.data, '$.type') = 'text'
720
+ WHERE json_extract(p.data, '$.type') IN ('text', 'reasoning', 'tool', 'patch')
681
721
  AND json_extract(m.data, '$.role') IN ('user', 'assistant')
682
- AND json_extract(p.data, '$.text') IS NOT NULL
683
722
  ORDER BY p.time_created DESC
684
723
  `).all()
685
- const insert = index.query<Row, [string, string, string, string, string, string, number, string]>(`
686
- INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, time_created, text)
687
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
724
+ const insert = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], number, string]>(`
725
+ INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, time_created, text)
726
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
688
727
  `)
689
728
  index.exec("BEGIN IMMEDIATE")
690
729
  try {
691
730
  index.exec("DELETE FROM document_fts")
692
- for (const row of rows) {
731
+ for (const row of rows.flatMap(sourceRowToSearchRow)) {
693
732
  insert.run(
694
733
  row.id,
695
734
  row.message_id,
@@ -697,6 +736,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
697
736
  row.session_title ?? "Untitled session",
698
737
  row.directory,
699
738
  row.role,
739
+ row.part_type ?? "text",
700
740
  row.time_created,
701
741
  row.text,
702
742
  )
@@ -704,6 +744,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
704
744
  setMeta(index, "source_path", sourcePath)
705
745
  setMeta(index, "source_data_version", String(state.dataVersion))
706
746
  setMeta(index, "source_mtime_ms", String(state.mtimeMs))
747
+ setMeta(index, "index_version", SEARCH_INDEX_VERSION)
707
748
  index.exec("COMMIT")
708
749
  } catch (err) {
709
750
  index.exec("ROLLBACK")
@@ -775,6 +816,29 @@ function extractFromValue(value: unknown): string {
775
816
  .join("\n")
776
817
  }
777
818
 
819
+ function sourceRowToSearchRow(row: SourceRow): Row[] {
820
+ const text = extractSearchText(row.data)
821
+ if (!text) return []
822
+ return [{ ...row, text }]
823
+ }
824
+
825
+ function searchablePartText(value: unknown): string {
826
+ if (!value || typeof value !== "object" || Array.isArray(value)) return extractFromValue(value)
827
+ const record = value as Record<string, unknown>
828
+ const type = record.type
829
+
830
+ if (type === "text" || type === "reasoning") return typeof record.text === "string" ? record.text : extractFromValue(record)
831
+ if (type === "patch") return extractFromValue(record.files)
832
+
833
+ if (type === "tool") {
834
+ const state = record.state && typeof record.state === "object" && !Array.isArray(record.state) ? record.state as Record<string, unknown> : undefined
835
+ if (record.tool !== "apply_patch") return ""
836
+ return extractFromValue(state?.input)
837
+ }
838
+
839
+ return extractFromValue(record)
840
+ }
841
+
778
842
  function truncate(value: string, length: number) {
779
843
  if (value.length <= length) return value
780
844
  return `${value.slice(0, length - 3)}...`