@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 +39 -1
- package/components/result-list.tsx +2 -2
- package/package.json +1 -1
- package/search.ts +93 -29
- package/telescope.tsx +424 -138
- package/tui.tsx +4 -2
- package/ui/config.test.ts +48 -0
- package/ui/config.ts +93 -0
- package/ui/keyboard.test.ts +40 -0
- package/ui/keyboard.ts +34 -0
- package/ui/render-target.ts +2 -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.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:
|
|
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:
|
|
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
|
|
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')
|
|
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("
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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')
|
|
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)}...`
|