@bojackduy/opencode-telescope 0.1.18 → 0.1.20
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/components/preview.tsx +3 -1
- package/package.json +1 -1
- package/search.ts +155 -4
- package/telescope.tsx +166 -10
- package/tui.tsx +6 -0
- package/ui/render-target.ts +3 -2
package/components/preview.tsx
CHANGED
|
@@ -51,7 +51,9 @@ export const ConversationPreview = (props: { item: SearchResult; parts: Conversa
|
|
|
51
51
|
<Show when={props.parts.length > 0} fallback={<ConversationFallback item={props.item} syntax={props.syntax} theme={props.theme} />}>
|
|
52
52
|
<For each={props.parts}>
|
|
53
53
|
{(part) => (
|
|
54
|
-
<
|
|
54
|
+
<box id={`preview-part-${part.id}`} flexDirection="column" flexShrink={0}>
|
|
55
|
+
<PreviewConversationPart part={part} item={props.item} syntax={props.syntax} theme={props.theme} />
|
|
56
|
+
</box>
|
|
55
57
|
)}
|
|
56
58
|
</For>
|
|
57
59
|
</Show>
|
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.20",
|
|
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
|
@@ -95,6 +95,7 @@ let _db: Database | undefined
|
|
|
95
95
|
let _dbPath: string | undefined
|
|
96
96
|
let _indexDb: Database | undefined
|
|
97
97
|
let _indexDbPath: string | undefined
|
|
98
|
+
const backgroundIndexRebuilds = new Set<string>()
|
|
98
99
|
|
|
99
100
|
function getDb(dbPath?: string): Database {
|
|
100
101
|
const resolved = dbPath ?? resolveDatabasePath()
|
|
@@ -148,8 +149,13 @@ export function searchSessionMessages(query: string, options?: { limit?: number;
|
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string; role?: SearchRole }) {
|
|
151
|
-
const
|
|
152
|
-
|
|
152
|
+
const dbPath = options?.dbPath ?? resolveDatabasePath()
|
|
153
|
+
const db = getDb(dbPath)
|
|
154
|
+
const limit = options?.limit ?? 40
|
|
155
|
+
const indexed = dbPath === ":memory:" ? undefined : indexedRecentRows(db, dbPath, limit, options?.directory, options?.offset, options?.role)
|
|
156
|
+
if (indexed) return indexed.flatMap((row) => rowToSearchResult(row, "") ?? [])
|
|
157
|
+
debug.log("query:recent:source-fallback", { limit, offset: options?.offset ?? 0, directory: options?.directory, role: options?.role })
|
|
158
|
+
return visibleTextRows(db, limit, undefined, options?.directory, options?.offset, options?.role).flatMap(
|
|
153
159
|
(row) => rowToSearchResult(row, "") ?? [],
|
|
154
160
|
)
|
|
155
161
|
}
|
|
@@ -592,6 +598,7 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
|
|
|
592
598
|
if (!match) return []
|
|
593
599
|
try {
|
|
594
600
|
const index = ensureSearchIndex(db, dbPath)
|
|
601
|
+
if (!index) return []
|
|
595
602
|
const conditions = ["document_fts MATCH ?"]
|
|
596
603
|
const params: (string | number)[] = [match]
|
|
597
604
|
if (role) {
|
|
@@ -622,6 +629,65 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
|
|
|
622
629
|
}
|
|
623
630
|
}
|
|
624
631
|
|
|
632
|
+
function indexedRecentRows(db: Database, dbPath: string, limit: number, directory?: string, offset?: number, role?: SearchRole) {
|
|
633
|
+
try {
|
|
634
|
+
const index = ensureSearchIndex(db, dbPath, { rebuild: false, useStale: true })
|
|
635
|
+
if (!index) {
|
|
636
|
+
debug.log("query:recent:index:missing", { dbPath })
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const rowCount = index.query<{ count: number }, []>("SELECT COUNT(*) as count FROM document_index").get()?.count ?? 0
|
|
641
|
+
if (rowCount === 0) {
|
|
642
|
+
debug.log("query:recent:index:empty", { dbPath })
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const state = sourceState(db, dbPath)
|
|
647
|
+
const currentDataVersion = getMeta(index, "source_data_version")
|
|
648
|
+
const currentMtimeMs = getMeta(index, "source_mtime_ms")
|
|
649
|
+
const currentPath = getMeta(index, "source_path")
|
|
650
|
+
const currentIndexVersion = getMeta(index, "index_version")
|
|
651
|
+
if (currentPath !== dbPath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
|
|
652
|
+
debug.log("query:recent:index:stale", {
|
|
653
|
+
dbPath,
|
|
654
|
+
expectedDataVersion: state.dataVersion,
|
|
655
|
+
actualDataVersion: currentDataVersion,
|
|
656
|
+
})
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const conditions: string[] = ["part_type = 'text'"]
|
|
660
|
+
const params: (string | number)[] = []
|
|
661
|
+
if (role) {
|
|
662
|
+
conditions.push("role = ?")
|
|
663
|
+
params.push(role)
|
|
664
|
+
}
|
|
665
|
+
if (directory) {
|
|
666
|
+
conditions.push("directory = ?")
|
|
667
|
+
params.push(directory)
|
|
668
|
+
}
|
|
669
|
+
params.push(limit)
|
|
670
|
+
if (offset) params.push(offset)
|
|
671
|
+
|
|
672
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
|
|
673
|
+
const offsetClause = offset ? "OFFSET ?" : ""
|
|
674
|
+
debug.time("query:recent:index:exec")
|
|
675
|
+
const rows = index.query<Row, (string | number)[]>(`
|
|
676
|
+
SELECT id, message_id, session_id, session_title, directory, role, part_type, tool,
|
|
677
|
+
CAST(time_created AS INTEGER) AS time_created, text
|
|
678
|
+
FROM document_index
|
|
679
|
+
${where}
|
|
680
|
+
ORDER BY CAST(time_created AS INTEGER) DESC
|
|
681
|
+
LIMIT ? ${offsetClause}
|
|
682
|
+
`).all(...params as any[])
|
|
683
|
+
debug.timeEnd("query:recent:index:exec")
|
|
684
|
+
return rows
|
|
685
|
+
} catch (err) {
|
|
686
|
+
debug.log("recent:index:fallback", err instanceof Error ? err.message : String(err))
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
625
691
|
function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
|
|
626
692
|
const offsetClause = offset ? "OFFSET ?" : ""
|
|
627
693
|
const conditions: string[] = [
|
|
@@ -664,7 +730,7 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
|
|
|
664
730
|
return rows
|
|
665
731
|
}
|
|
666
732
|
|
|
667
|
-
function ensureSearchIndex(source: Database, sourcePath: string) {
|
|
733
|
+
function ensureSearchIndex(source: Database, sourcePath: string, options?: { rebuild?: boolean; useStale?: boolean }) {
|
|
668
734
|
const indexPath = searchIndexPath(sourcePath)
|
|
669
735
|
if (!_indexDb || _indexDbPath !== indexPath) {
|
|
670
736
|
_indexDb?.close()
|
|
@@ -679,12 +745,35 @@ function ensureSearchIndex(source: Database, sourcePath: string) {
|
|
|
679
745
|
const currentPath = getMeta(_indexDb, "source_path")
|
|
680
746
|
const currentIndexVersion = getMeta(_indexDb, "index_version")
|
|
681
747
|
if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
|
|
748
|
+
if (options?.rebuild === false) {
|
|
749
|
+
if (options?.useStale) return _indexDb
|
|
750
|
+
return
|
|
751
|
+
}
|
|
682
752
|
rebuildSearchIndex(source, _indexDb, sourcePath, state)
|
|
683
753
|
}
|
|
684
754
|
return _indexDb
|
|
685
755
|
}
|
|
686
756
|
|
|
687
|
-
|
|
757
|
+
function scheduleBackgroundIndexRebuild(dbPath: string) {
|
|
758
|
+
if (backgroundIndexRebuilds.has(dbPath)) return
|
|
759
|
+
backgroundIndexRebuilds.add(dbPath)
|
|
760
|
+
debug.log("fts:rebuild:background-scheduled", { dbPath })
|
|
761
|
+
const timer = setTimeout(() => {
|
|
762
|
+
debug.time("fts:rebuild:background")
|
|
763
|
+
try {
|
|
764
|
+
const db = getDb(dbPath)
|
|
765
|
+
ensureSearchIndex(db, dbPath)
|
|
766
|
+
} catch (err) {
|
|
767
|
+
debug.log("fts:rebuild:background:error", err instanceof Error ? err.message : String(err))
|
|
768
|
+
} finally {
|
|
769
|
+
backgroundIndexRebuilds.delete(dbPath)
|
|
770
|
+
debug.timeEnd("fts:rebuild:background")
|
|
771
|
+
}
|
|
772
|
+
}, 250)
|
|
773
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const SEARCH_INDEX_VERSION = "6"
|
|
688
777
|
|
|
689
778
|
function searchIndexPath(sourcePath: string) {
|
|
690
779
|
const parsed = path.parse(sourcePath)
|
|
@@ -710,6 +799,24 @@ function migrateSearchIndex(db: Database) {
|
|
|
710
799
|
text,
|
|
711
800
|
tokenize='unicode61'
|
|
712
801
|
);
|
|
802
|
+
CREATE TABLE IF NOT EXISTS document_index(
|
|
803
|
+
id TEXT PRIMARY KEY,
|
|
804
|
+
message_id TEXT NOT NULL,
|
|
805
|
+
session_id TEXT NOT NULL,
|
|
806
|
+
session_title TEXT NOT NULL,
|
|
807
|
+
directory TEXT NOT NULL,
|
|
808
|
+
role TEXT NOT NULL,
|
|
809
|
+
part_type TEXT NOT NULL,
|
|
810
|
+
tool TEXT,
|
|
811
|
+
time_created INTEGER NOT NULL,
|
|
812
|
+
text TEXT NOT NULL
|
|
813
|
+
);
|
|
814
|
+
CREATE INDEX IF NOT EXISTS document_index_recent_idx
|
|
815
|
+
ON document_index(directory, role, time_created DESC);
|
|
816
|
+
CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
|
|
817
|
+
ON document_index(directory, role, part_type, time_created DESC);
|
|
818
|
+
CREATE INDEX IF NOT EXISTS document_index_time_idx
|
|
819
|
+
ON document_index(time_created DESC);
|
|
713
820
|
`)
|
|
714
821
|
|
|
715
822
|
const columns = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().map((column) => column.name)
|
|
@@ -731,6 +838,33 @@ function migrateSearchIndex(db: Database) {
|
|
|
731
838
|
);
|
|
732
839
|
`)
|
|
733
840
|
}
|
|
841
|
+
|
|
842
|
+
const indexColumns = db.query<{ name: string }, []>("PRAGMA table_info(document_index)").all().map((column) => column.name)
|
|
843
|
+
if (!["part_type", "tool", "time_created", "text"].every((name) => indexColumns.includes(name))) {
|
|
844
|
+
db.exec("DROP TABLE IF EXISTS document_index")
|
|
845
|
+
db.exec(`
|
|
846
|
+
CREATE TABLE document_index(
|
|
847
|
+
id TEXT PRIMARY KEY,
|
|
848
|
+
message_id TEXT NOT NULL,
|
|
849
|
+
session_id TEXT NOT NULL,
|
|
850
|
+
session_title TEXT NOT NULL,
|
|
851
|
+
directory TEXT NOT NULL,
|
|
852
|
+
role TEXT NOT NULL,
|
|
853
|
+
part_type TEXT NOT NULL,
|
|
854
|
+
tool TEXT,
|
|
855
|
+
time_created INTEGER NOT NULL,
|
|
856
|
+
text TEXT NOT NULL
|
|
857
|
+
);
|
|
858
|
+
`)
|
|
859
|
+
}
|
|
860
|
+
db.exec(`
|
|
861
|
+
CREATE INDEX IF NOT EXISTS document_index_recent_idx
|
|
862
|
+
ON document_index(directory, role, time_created DESC);
|
|
863
|
+
CREATE INDEX IF NOT EXISTS document_index_recent_text_idx
|
|
864
|
+
ON document_index(directory, role, part_type, time_created DESC);
|
|
865
|
+
CREATE INDEX IF NOT EXISTS document_index_time_idx
|
|
866
|
+
ON document_index(time_created DESC);
|
|
867
|
+
`)
|
|
734
868
|
}
|
|
735
869
|
|
|
736
870
|
function sourceState(db: Database, sourcePath: string) {
|
|
@@ -765,9 +899,14 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
765
899
|
INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
|
|
766
900
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
767
901
|
`)
|
|
902
|
+
const insertIndex = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], string | null, number, string]>(`
|
|
903
|
+
INSERT INTO document_index(id, message_id, session_id, session_title, directory, role, part_type, tool, time_created, text)
|
|
904
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
905
|
+
`)
|
|
768
906
|
index.exec("BEGIN IMMEDIATE")
|
|
769
907
|
try {
|
|
770
908
|
index.exec("DELETE FROM document_fts")
|
|
909
|
+
index.exec("DELETE FROM document_index")
|
|
771
910
|
for (const row of rows.flatMap(indexSourceRowToRows)) {
|
|
772
911
|
insert.run(
|
|
773
912
|
row.id,
|
|
@@ -781,6 +920,18 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
|
|
|
781
920
|
row.time_created,
|
|
782
921
|
row.text,
|
|
783
922
|
)
|
|
923
|
+
insertIndex.run(
|
|
924
|
+
row.id,
|
|
925
|
+
row.message_id,
|
|
926
|
+
row.session_id,
|
|
927
|
+
row.session_title ?? "Untitled session",
|
|
928
|
+
row.directory,
|
|
929
|
+
row.role,
|
|
930
|
+
row.part_type ?? "text",
|
|
931
|
+
row.tool ?? null,
|
|
932
|
+
row.time_created,
|
|
933
|
+
row.text,
|
|
934
|
+
)
|
|
784
935
|
}
|
|
785
936
|
setMeta(index, "source_path", sourcePath)
|
|
786
937
|
setMeta(index, "source_data_version", String(state.dataVersion))
|
package/telescope.tsx
CHANGED
|
@@ -20,9 +20,10 @@ import { debug } from "./ui/debug.ts"
|
|
|
20
20
|
import { syntaxStyle } from "./ui/format.ts"
|
|
21
21
|
import type { TelescopeConfig } from "./ui/config.ts"
|
|
22
22
|
import { inputSafeKeys, keyListLabel, matchesKey, prevent } from "./ui/keyboard.ts"
|
|
23
|
-
import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
|
|
23
|
+
import { findRenderableByID, jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
|
|
24
24
|
|
|
25
25
|
export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; onClose: () => void }) => {
|
|
26
|
+
debug.log("component:render:start")
|
|
26
27
|
type OwnerFilter = "all" | SearchRole
|
|
27
28
|
const dimensions = useTerminalDimensions()
|
|
28
29
|
const [query, setQuery] = createSignal("")
|
|
@@ -46,11 +47,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
46
47
|
const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
|
|
47
48
|
const [prefetchingPreviewBefore, setPrefetchingPreviewBefore] = createSignal(false)
|
|
48
49
|
const [prefetchingPreviewAfter, setPrefetchingPreviewAfter] = createSignal(false)
|
|
50
|
+
const [previewWindow, setPreviewWindow] = createSignal({ start: 0, end: 0 })
|
|
51
|
+
const [previewHeightVersion, setPreviewHeightVersion] = createSignal(0)
|
|
49
52
|
const MIN_SEARCH_BATCH_SIZE = 25
|
|
50
53
|
const MIN_RECENT_BATCH_SIZE = 15
|
|
51
54
|
const RESULT_OVERSCAN_MULTIPLIER = 2
|
|
52
55
|
const RESULT_BATCH_VIEWPORTS = 4
|
|
53
|
-
const RESULT_PREFETCH_VIEWPORTS =
|
|
56
|
+
const RESULT_PREFETCH_VIEWPORTS = 3
|
|
54
57
|
const RESULT_CACHE_BEHIND_VIEWPORTS = 6
|
|
55
58
|
const INITIAL_PREVIEW_BEFORE = 20
|
|
56
59
|
const INITIAL_PREVIEW_AFTER = 30
|
|
@@ -87,12 +90,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
87
90
|
const directory = props.api.state.path.directory
|
|
88
91
|
let advanceSelectionAfterLoad = false
|
|
89
92
|
let advanceSelectionBeforeLoad = false
|
|
93
|
+
let resultNavigationStarted = false
|
|
90
94
|
let resultPrefetchTimer: ReturnType<typeof setTimeout> | undefined
|
|
91
95
|
let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
|
|
92
96
|
let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
|
|
93
97
|
let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
|
|
94
98
|
let pendingPreviewBefore: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
|
|
95
99
|
let pendingPreviewAfterVisible = false
|
|
100
|
+
const previewMeasuredHeights = new Map<string, number>()
|
|
96
101
|
const cancelPreviewPrefetch = () => {
|
|
97
102
|
if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
|
|
98
103
|
if (previewAfterTimer) clearTimeout(previewAfterTimer)
|
|
@@ -177,6 +182,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
177
182
|
resultPreviousTimer = undefined
|
|
178
183
|
advanceSelectionAfterLoad = false
|
|
179
184
|
advanceSelectionBeforeLoad = false
|
|
185
|
+
resultNavigationStarted = false
|
|
180
186
|
setLoadingMore(false)
|
|
181
187
|
setLoadingPreviousResults(false)
|
|
182
188
|
setPrefetchingResults(false)
|
|
@@ -187,9 +193,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
187
193
|
setLoading(true)
|
|
188
194
|
const limit = recentBatchSize()
|
|
189
195
|
const timer = setTimeout(() => {
|
|
196
|
+
debug.log("bootstrap:recent:start", { limit, directory: dir, role })
|
|
190
197
|
debug.time("query:recent")
|
|
191
198
|
try {
|
|
192
199
|
const batch = recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
|
|
200
|
+
debug.log("bootstrap:recent:done", { rows: batch.length, limit })
|
|
193
201
|
solidBatch(() => {
|
|
194
202
|
setResults(batch)
|
|
195
203
|
setResultBaseOffset(0)
|
|
@@ -199,6 +207,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
199
207
|
setSelected(0)
|
|
200
208
|
})
|
|
201
209
|
} catch (err) {
|
|
210
|
+
debug.log("bootstrap:recent:error", err instanceof Error ? err.message : String(err))
|
|
202
211
|
solidBatch(() => {
|
|
203
212
|
setResults([])
|
|
204
213
|
setResultBaseOffset(0)
|
|
@@ -209,6 +218,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
209
218
|
}
|
|
210
219
|
debug.timeEnd("query:recent")
|
|
211
220
|
setLoading(false)
|
|
221
|
+
debug.log("component:interactive")
|
|
212
222
|
}, 1)
|
|
213
223
|
onCleanup(() => clearTimeout(timer))
|
|
214
224
|
return
|
|
@@ -220,7 +230,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
220
230
|
const timer = setTimeout(() => {
|
|
221
231
|
debug.time("query:search")
|
|
222
232
|
try {
|
|
233
|
+
debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q })
|
|
223
234
|
const batch = searchSessionMessages(q, { limit, offset: 0, dbPath: db, directory: dir, role })
|
|
235
|
+
debug.log("bootstrap:search:done", { rows: batch.length, limit })
|
|
224
236
|
solidBatch(() => {
|
|
225
237
|
setResults(batch)
|
|
226
238
|
setResultBaseOffset(0)
|
|
@@ -370,6 +382,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
370
382
|
|
|
371
383
|
const move = (delta: number) => {
|
|
372
384
|
if (results().length === 0) return
|
|
385
|
+
resultNavigationStarted = true
|
|
373
386
|
setSelected((index) => {
|
|
374
387
|
const base = resultBaseOffset()
|
|
375
388
|
const cachedEnd = base + results().length
|
|
@@ -409,6 +422,8 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
409
422
|
const state = resultPrefetchState()
|
|
410
423
|
const blockedBy = !hasMore()
|
|
411
424
|
? "no-more"
|
|
425
|
+
: !resultNavigationStarted
|
|
426
|
+
? "waiting-for-navigation"
|
|
412
427
|
: loadingMore()
|
|
413
428
|
? "loading-more"
|
|
414
429
|
: loadingPreviousResults()
|
|
@@ -431,10 +446,76 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
431
446
|
onCleanup(() => clearTimeout(timer))
|
|
432
447
|
})
|
|
433
448
|
|
|
434
|
-
const
|
|
435
|
-
|
|
449
|
+
const estimatePreviewPartHeight = (part: ConversationPreviewPart) => {
|
|
450
|
+
if (part.type === "tool") {
|
|
451
|
+
if (!part.target) return 2
|
|
452
|
+
if (part.tool === "write") return 40
|
|
453
|
+
if (part.tool === "edit" || part.tool === "apply_patch") return 35
|
|
454
|
+
return 2
|
|
455
|
+
}
|
|
456
|
+
if (part.type === "reasoning") return Math.min(24, Math.max(2, Math.ceil(part.text.length / 120)))
|
|
457
|
+
return Math.min(90, Math.max(3, Math.ceil(part.text.length / 90)))
|
|
436
458
|
}
|
|
437
459
|
|
|
460
|
+
const previewPartHeight = (part: ConversationPreviewPart) => previewMeasuredHeights.get(part.id) ?? estimatePreviewPartHeight(part)
|
|
461
|
+
|
|
462
|
+
const previewVirtualLayout = createMemo(() => {
|
|
463
|
+
previewHeightVersion()
|
|
464
|
+
let offset = 0
|
|
465
|
+
return previewParts().map((part) => {
|
|
466
|
+
const height = previewPartHeight(part)
|
|
467
|
+
const row = { part, top: offset, bottom: offset + height, height }
|
|
468
|
+
offset += height
|
|
469
|
+
return row
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
const previewContentHeight = () => previewVirtualLayout().at(-1)?.bottom ?? 0
|
|
474
|
+
|
|
475
|
+
const updatePreviewWindow = () => {
|
|
476
|
+
const scroll = previewScroll
|
|
477
|
+
const layout = previewVirtualLayout()
|
|
478
|
+
if (layout.length === 0) {
|
|
479
|
+
setPreviewWindow({ start: 0, end: 0 })
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
if (!scroll) {
|
|
483
|
+
const next = { start: 0, end: Math.min(layout.length, 20) }
|
|
484
|
+
const current = previewWindow()
|
|
485
|
+
if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const overscan = Math.max(scroll.height * 2, 40)
|
|
490
|
+
const from = Math.max(0, scroll.scrollTop - overscan)
|
|
491
|
+
const to = scroll.scrollTop + scroll.height + overscan
|
|
492
|
+
const foundStart = layout.findIndex((row) => row.bottom >= from)
|
|
493
|
+
const start = foundStart === -1 ? Math.max(0, layout.length - 1) : Math.max(0, foundStart)
|
|
494
|
+
const foundEnd = layout.findIndex((row) => row.top > to)
|
|
495
|
+
const end = foundEnd === -1 ? layout.length : foundEnd
|
|
496
|
+
const next = { start, end: Math.min(layout.length, Math.max(start + 1, end)) }
|
|
497
|
+
const current = previewWindow()
|
|
498
|
+
if (current.start !== next.start || current.end !== next.end) setPreviewWindow(next)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const previewWindowParts = createMemo(() => {
|
|
502
|
+
const { start, end } = previewWindow()
|
|
503
|
+
return previewParts().slice(start, end)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
const previewTopSpacerHeight = createMemo(() => {
|
|
507
|
+
const { start } = previewWindow()
|
|
508
|
+
return previewVirtualLayout()[start]?.top ?? 0
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
const previewBottomSpacerHeight = createMemo(() => {
|
|
512
|
+
const { end } = previewWindow()
|
|
513
|
+
const layout = previewVirtualLayout()
|
|
514
|
+
const total = layout.at(-1)?.bottom ?? 0
|
|
515
|
+
const renderedBottom = end > 0 ? layout[end - 1]?.bottom ?? 0 : 0
|
|
516
|
+
return Math.max(0, total - renderedBottom)
|
|
517
|
+
})
|
|
518
|
+
|
|
438
519
|
const previewScrollState = () => {
|
|
439
520
|
const scroll = previewScroll
|
|
440
521
|
const children = scroll?.getChildren()
|
|
@@ -456,6 +537,45 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
456
537
|
}
|
|
457
538
|
}
|
|
458
539
|
|
|
540
|
+
const measurePreviewWindow = () => {
|
|
541
|
+
const scroll = previewScroll
|
|
542
|
+
if (!scroll) return
|
|
543
|
+
|
|
544
|
+
let changed = false
|
|
545
|
+
for (const part of previewWindowParts()) {
|
|
546
|
+
const node = findRenderableByID(scroll, `preview-part-${part.id}`)
|
|
547
|
+
const height = node?.height
|
|
548
|
+
if (!height || height <= 0) continue
|
|
549
|
+
if (previewMeasuredHeights.get(part.id) !== height) {
|
|
550
|
+
previewMeasuredHeights.set(part.id, height)
|
|
551
|
+
changed = true
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (changed) {
|
|
556
|
+
debug.log("preview:measure", {
|
|
557
|
+
measured: previewWindowParts().length,
|
|
558
|
+
totalMeasured: previewMeasuredHeights.size,
|
|
559
|
+
window: previewWindow(),
|
|
560
|
+
})
|
|
561
|
+
setPreviewHeightVersion((value) => value + 1)
|
|
562
|
+
setTimeout(updatePreviewWindow, 1)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
createEffect(() => {
|
|
567
|
+
previewParts()
|
|
568
|
+
previewHeightVersion()
|
|
569
|
+
const timer = setTimeout(updatePreviewWindow, 1)
|
|
570
|
+
onCleanup(() => clearTimeout(timer))
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
createEffect(() => {
|
|
574
|
+
previewWindowParts()
|
|
575
|
+
const timer = setTimeout(measurePreviewWindow, 1)
|
|
576
|
+
onCleanup(() => clearTimeout(timer))
|
|
577
|
+
})
|
|
578
|
+
|
|
459
579
|
const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
|
|
460
580
|
const item = selectedResult()
|
|
461
581
|
const first = previewParts()[0]
|
|
@@ -498,6 +618,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
498
618
|
const beforeAdjust = previewScrollState()
|
|
499
619
|
const delta = previewContentHeight() - previousContentHeight
|
|
500
620
|
if (delta > 0) previewScroll?.scrollBy(delta)
|
|
621
|
+
updatePreviewWindow()
|
|
501
622
|
debug.log("preview:load-before:adjust", {
|
|
502
623
|
delta,
|
|
503
624
|
previousContentHeight,
|
|
@@ -509,6 +630,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
509
630
|
}, 1)
|
|
510
631
|
} else {
|
|
511
632
|
setTimeout(() => {
|
|
633
|
+
updatePreviewWindow()
|
|
512
634
|
debug.log("preview:load-before:no-adjust", {
|
|
513
635
|
previousContentHeight,
|
|
514
636
|
newContentHeight: previewContentHeight(),
|
|
@@ -555,7 +677,10 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
555
677
|
first: page.parts[0]?.id,
|
|
556
678
|
last: page.parts.at(-1)?.id,
|
|
557
679
|
})
|
|
558
|
-
if (page.parts.length > 0)
|
|
680
|
+
if (page.parts.length > 0) {
|
|
681
|
+
setPreviewParts((prev) => [...prev, ...page.parts])
|
|
682
|
+
setTimeout(updatePreviewWindow, 1)
|
|
683
|
+
}
|
|
559
684
|
setHasMorePreviewAfter(page.hasMoreAfter)
|
|
560
685
|
} catch (err) {
|
|
561
686
|
debug.log("preview:load-after:error", err instanceof Error ? err.message : String(err))
|
|
@@ -631,12 +756,32 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
631
756
|
}, 1)
|
|
632
757
|
}
|
|
633
758
|
|
|
759
|
+
const scrollPreviewToEstimatedTarget = (item: SearchResult) => {
|
|
760
|
+
const scroll = previewScroll
|
|
761
|
+
if (!scroll) return
|
|
762
|
+
const targetID = messageTargetID(item)
|
|
763
|
+
const row = previewVirtualLayout().find((entry) => entry.part.id === item.id)
|
|
764
|
+
if (!row) {
|
|
765
|
+
scrollPreviewToTarget(scroll, targetID)
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const top = Math.max(0, row.top - Math.max(1, Math.floor(scroll.height / 3)))
|
|
770
|
+
debug.log("preview:target-scroll:estimated", { targetID, targetTop: row.top, scrollTop: scroll.scrollTop, nextScrollTop: top, window: previewWindow() })
|
|
771
|
+
scroll.scrollTo(top)
|
|
772
|
+
updatePreviewWindow()
|
|
773
|
+
setTimeout(() => scrollPreviewToTarget(scroll, targetID), 1)
|
|
774
|
+
}
|
|
775
|
+
|
|
634
776
|
let lastPreviewItemId = ""
|
|
635
777
|
createEffect(() => {
|
|
636
778
|
const item = selectedResult()
|
|
637
779
|
if (!item) {
|
|
638
780
|
cancelPreviewPrefetch()
|
|
639
781
|
setPreviewParts([])
|
|
782
|
+
previewMeasuredHeights.clear()
|
|
783
|
+
setPreviewHeightVersion((value) => value + 1)
|
|
784
|
+
setPreviewWindow({ start: 0, end: 0 })
|
|
640
785
|
setHasMorePreviewBefore(false)
|
|
641
786
|
setHasMorePreviewAfter(false)
|
|
642
787
|
return
|
|
@@ -644,6 +789,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
644
789
|
if (item.id === lastPreviewItemId) return
|
|
645
790
|
lastPreviewItemId = item.id
|
|
646
791
|
cancelPreviewPrefetch()
|
|
792
|
+
previewMeasuredHeights.clear()
|
|
793
|
+
setPreviewHeightVersion((value) => value + 1)
|
|
794
|
+
setPreviewWindow({ start: 0, end: 0 })
|
|
647
795
|
debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
|
|
648
796
|
const db = dbPath()
|
|
649
797
|
debug.time("preview:load")
|
|
@@ -661,6 +809,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
661
809
|
setPreviewParts(page.parts)
|
|
662
810
|
setHasMorePreviewBefore(page.hasMoreBefore)
|
|
663
811
|
setHasMorePreviewAfter(page.hasMoreAfter)
|
|
812
|
+
setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
|
|
664
813
|
} catch {}
|
|
665
814
|
debug.timeEnd("nav:total")
|
|
666
815
|
debug.timeEnd("preview:load")
|
|
@@ -674,7 +823,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
674
823
|
const scroll = previewScroll
|
|
675
824
|
const children = scroll?.getChildren()
|
|
676
825
|
if (!scroll || !children || children.length === 0) return
|
|
677
|
-
const totalContentHeight =
|
|
826
|
+
const totalContentHeight = previewContentHeight()
|
|
678
827
|
const atTop = scroll.scrollTop <= 0
|
|
679
828
|
const prefetchDistance = Math.max(2, Math.floor(scroll.height * PREVIEW_PREFETCH_VIEWPORTS))
|
|
680
829
|
const nearTop = scroll.scrollTop <= prefetchDistance
|
|
@@ -708,11 +857,11 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
708
857
|
let scrolledItem = ""
|
|
709
858
|
createEffect(() => {
|
|
710
859
|
const item = selectedResult()
|
|
711
|
-
|
|
860
|
+
previewVirtualLayout()
|
|
712
861
|
if (!item) return
|
|
713
862
|
if (item.id === scrolledItem) return
|
|
714
863
|
scrolledItem = item.id
|
|
715
|
-
const timer = setTimeout(() =>
|
|
864
|
+
const timer = setTimeout(() => scrollPreviewToEstimatedTarget(item), 1)
|
|
716
865
|
onCleanup(() => clearTimeout(timer))
|
|
717
866
|
})
|
|
718
867
|
|
|
@@ -742,7 +891,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
742
891
|
return
|
|
743
892
|
}
|
|
744
893
|
|
|
745
|
-
const totalContentHeight =
|
|
894
|
+
const totalContentHeight = previewContentHeight()
|
|
746
895
|
if (direction > 0 && scroll.scrollTop + scroll.height >= totalContentHeight - 1 && hasMorePreviewAfter()) {
|
|
747
896
|
debug.log("preview:scroll-key:load-after", { direction, totalContentHeight, state: beforeState })
|
|
748
897
|
schedulePreviewAfter(true)
|
|
@@ -751,6 +900,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
751
900
|
|
|
752
901
|
const amount = direction * previewScrollAmount(scroll)
|
|
753
902
|
scroll.scrollBy(amount)
|
|
903
|
+
updatePreviewWindow()
|
|
754
904
|
debug.log("preview:scroll-key:scroll", { direction, amount, before: beforeState, after: previewScrollState() })
|
|
755
905
|
}
|
|
756
906
|
|
|
@@ -919,7 +1069,13 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
919
1069
|
<PreviewHeader item={selectedResult()} query={query()} theme={theme()} />
|
|
920
1070
|
<scrollbox ref={(element: ScrollBoxRenderable) => (previewScroll = element)} flexGrow={1} minHeight={0} paddingLeft={1} paddingRight={1} verticalScrollbarOptions={{ visible: true }}>
|
|
921
1071
|
<Show when={selectedResult()} fallback={<text fg={theme().textMuted}>Select a hit to preview the exact matched message.</text>}>
|
|
922
|
-
{(item) =>
|
|
1072
|
+
{(item) => (
|
|
1073
|
+
<box flexDirection="column" flexShrink={0}>
|
|
1074
|
+
<box height={previewTopSpacerHeight()} flexShrink={0} />
|
|
1075
|
+
<ConversationPreview item={item()} parts={previewWindowParts()} syntax={syntax()} theme={theme()} />
|
|
1076
|
+
<box height={previewBottomSpacerHeight()} flexShrink={0} />
|
|
1077
|
+
</box>
|
|
1078
|
+
)}
|
|
923
1079
|
</Show>
|
|
924
1080
|
</scrollbox>
|
|
925
1081
|
</box>
|
package/tui.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
3
|
import { Telescope } from "./telescope.tsx"
|
|
4
|
+
import { debug } from "./ui/debug.ts"
|
|
4
5
|
import { loadTelescopeConfig } from "./ui/config.ts"
|
|
5
6
|
|
|
6
7
|
const id = "opencode-telescope"
|
|
@@ -12,15 +13,19 @@ const enabled = (options: unknown) => {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
const tui: TuiPlugin = async (api: TuiPluginApi, options: unknown) => {
|
|
16
|
+
debug.log("plugin:setup:start")
|
|
15
17
|
if (!enabled(options)) return
|
|
16
18
|
|
|
17
19
|
const config = loadTelescopeConfig()
|
|
18
20
|
const command = "opencode.telescope.sessions"
|
|
19
21
|
const open = () => {
|
|
22
|
+
debug.log("plugin:dialog:open:start")
|
|
20
23
|
api.ui.dialog.replace(() => <Telescope api={api} config={config} onClose={() => api.ui.dialog.clear()} />)
|
|
21
24
|
api.ui.dialog.setSize("xlarge")
|
|
25
|
+
debug.log("plugin:dialog:open:done")
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
debug.log("plugin:setup:register-keymap")
|
|
24
29
|
const unregisterKeymap = api.keymap.registerLayer({
|
|
25
30
|
commands: [
|
|
26
31
|
{
|
|
@@ -38,6 +43,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi, options: unknown) => {
|
|
|
38
43
|
api.lifecycle.onDispose(() => {
|
|
39
44
|
unregisterKeymap()
|
|
40
45
|
})
|
|
46
|
+
debug.log("plugin:setup:done")
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
const plugin: TuiPluginModule & { id: string } = {
|
package/ui/render-target.ts
CHANGED
|
@@ -59,9 +59,10 @@ export function jumpToRenderedTarget(root: unknown, targetID: string) {
|
|
|
59
59
|
setTimeout(tick, 50)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
type RenderNode = {
|
|
62
|
+
export type RenderNode = {
|
|
63
63
|
id?: string
|
|
64
64
|
y: number
|
|
65
|
+
height?: number
|
|
65
66
|
getChildren(): unknown[]
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -94,7 +95,7 @@ function isScrollNode(value: RenderNode): value is ScrollNode {
|
|
|
94
95
|
return "scrollBy" in value && typeof value.scrollBy === "function"
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
|
|
98
|
+
export function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
|
|
98
99
|
if (!isRenderNode(node)) return
|
|
99
100
|
if (node.id === targetID) return node
|
|
100
101
|
for (const child of node.getChildren()) {
|