@bojackduy/opencode-telescope 0.1.2 → 0.1.3
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 +2 -2
- package/components/result-list.tsx +22 -0
- package/package.json +1 -1
- package/search.ts +10 -7
- package/telescope.tsx +122 -65
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@ This plugin searches OpenCode's local SQLite session database directly in read-o
|
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
|
-
Add the plugin to your `
|
|
9
|
+
Add the plugin to your `tui.json`:
|
|
10
10
|
|
|
11
11
|
```jsonc
|
|
12
12
|
{
|
|
13
|
-
"plugin": ["@bojackduy/opencode-telescope"]
|
|
13
|
+
"plugin": ["@bojackduy/opencode-telescope"],
|
|
14
14
|
}
|
|
15
15
|
```
|
|
16
16
|
|
|
@@ -59,6 +59,28 @@ export const EmptyState = (props: { query: string; theme: TuiThemeCurrent }) =>
|
|
|
59
59
|
</box>
|
|
60
60
|
)
|
|
61
61
|
|
|
62
|
+
export const SkeletonRow = (props: { theme: TuiThemeCurrent }) => (
|
|
63
|
+
<box
|
|
64
|
+
flexDirection="column"
|
|
65
|
+
paddingLeft={2}
|
|
66
|
+
paddingRight={2}
|
|
67
|
+
paddingTop={0}
|
|
68
|
+
paddingBottom={1}
|
|
69
|
+
>
|
|
70
|
+
<text wrapMode="none" overflow="hidden">
|
|
71
|
+
<span style={{ fg: props.theme.textMuted }}> </span>
|
|
72
|
+
<span style={{ fg: props.theme.textMuted }}>────────────────────</span>
|
|
73
|
+
<span style={{ fg: props.theme.textMuted }}> </span>
|
|
74
|
+
<span style={{ fg: props.theme.textMuted }}>you</span>
|
|
75
|
+
<span style={{ fg: props.theme.textMuted }}> · -- --- --:--</span>
|
|
76
|
+
</text>
|
|
77
|
+
<text wrapMode="none" overflow="hidden">
|
|
78
|
+
<span style={{ fg: props.theme.textMuted }}> </span>
|
|
79
|
+
<span style={{ fg: props.theme.textMuted }}>∙ ∙ ∙ ∙ ∙ ∙ ∙ ∙ ∙ ∙ ∙ ∙ ∙ ∙ ∙ ∙</span>
|
|
80
|
+
</text>
|
|
81
|
+
</box>
|
|
82
|
+
)
|
|
83
|
+
|
|
62
84
|
const HighlightedText = (props: {
|
|
63
85
|
before: string
|
|
64
86
|
match: string
|
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.3",
|
|
5
5
|
"description": "Telescope-style session conversation search for OpenCode TUI",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
package/search.ts
CHANGED
|
@@ -67,20 +67,23 @@ type ConversationRow = {
|
|
|
67
67
|
data: string
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
let cachedDbPath: string | undefined
|
|
71
|
+
|
|
70
72
|
export function resolveDatabasePath() {
|
|
73
|
+
if (cachedDbPath) return cachedDbPath
|
|
71
74
|
if (process.env.OPENCODE_DB) {
|
|
72
|
-
if (process.env.OPENCODE_DB === ":memory:" || path.isAbsolute(process.env.OPENCODE_DB)) return process.env.OPENCODE_DB
|
|
73
|
-
return path.join(candidateDataDirs()[0] ?? defaultDataDir(), process.env.OPENCODE_DB)
|
|
75
|
+
if (process.env.OPENCODE_DB === ":memory:" || path.isAbsolute(process.env.OPENCODE_DB)) return cachedDbPath = process.env.OPENCODE_DB
|
|
76
|
+
return cachedDbPath = path.join(candidateDataDirs()[0] ?? defaultDataDir(), process.env.OPENCODE_DB)
|
|
74
77
|
}
|
|
75
78
|
if (process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || process.env.OPENCODE_DISABLE_CHANNEL_DB === "true") {
|
|
76
|
-
return requireExistingDatabase(["opencode.db"])
|
|
79
|
+
return cachedDbPath = requireExistingDatabase(["opencode.db"])
|
|
77
80
|
}
|
|
78
81
|
const stable = candidateDatabasePaths(["opencode.db"]).find(existsSync)
|
|
79
|
-
if (stable) return stable
|
|
82
|
+
if (stable) return cachedDbPath = stable
|
|
80
83
|
if (process.env.OPENCODE_CHANNEL) {
|
|
81
84
|
const channel = process.env.OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
82
85
|
const candidate = candidateDatabasePaths([`opencode-${channel}.db`]).find(existsSync)
|
|
83
|
-
if (candidate) return candidate
|
|
86
|
+
if (candidate) return cachedDbPath = candidate
|
|
84
87
|
}
|
|
85
88
|
const fallback = candidateDatabasePaths(["opencode.db"])[0] ?? path.join(defaultDataDir(), "opencode.db")
|
|
86
89
|
try {
|
|
@@ -91,9 +94,9 @@ export function resolveDatabasePath() {
|
|
|
91
94
|
.map((entry) => path.join(dir, entry.name)),
|
|
92
95
|
)
|
|
93
96
|
.at(0)
|
|
94
|
-
return discovered ?? fallback
|
|
97
|
+
return cachedDbPath = discovered ?? fallback
|
|
95
98
|
} catch {
|
|
96
|
-
return fallback
|
|
99
|
+
return cachedDbPath = fallback
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
|
package/telescope.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import type { InputRenderable, ParsedKey, ScrollBoxRenderable } from "@opentui/c
|
|
|
4
4
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
|
5
5
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
|
6
6
|
import { ConversationPreview, PreviewHeader } from "./components/preview.tsx"
|
|
7
|
-
import { EmptyState, ResultRow } from "./components/result-list.tsx"
|
|
7
|
+
import { EmptyState, ResultRow, SkeletonRow } from "./components/result-list.tsx"
|
|
8
8
|
import {
|
|
9
9
|
loadConversationWindow,
|
|
10
10
|
recentSessionMessages,
|
|
@@ -25,6 +25,8 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
25
25
|
const [selected, setSelected] = createSignal(0)
|
|
26
26
|
const [busy, setBusy] = createSignal(false)
|
|
27
27
|
const [error, setError] = createSignal("")
|
|
28
|
+
const [mode, setMode] = createSignal<"normal" | "insert">("normal")
|
|
29
|
+
const [loading, setLoading] = createSignal(true)
|
|
28
30
|
let input: InputRenderable | undefined
|
|
29
31
|
let resultScroll: ScrollBoxRenderable | undefined
|
|
30
32
|
let previewScroll: ScrollBoxRenderable | undefined
|
|
@@ -36,36 +38,39 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
36
38
|
const leftWidth = createMemo(() => Math.max(36, Math.min(64, Math.floor(popupWidth() * 0.36))))
|
|
37
39
|
const height = createMemo(() => Math.max(18, dimensions().height - 8))
|
|
38
40
|
const verticalOffset = createMemo(() => Math.floor(dimensions().height / 4 - height() / 2) - 2)
|
|
39
|
-
const dbPath = resolveDatabasePath()
|
|
41
|
+
const dbPath = createMemo(() => resolveDatabasePath())
|
|
40
42
|
const directory = props.api.state.path.directory
|
|
41
43
|
|
|
42
|
-
createEffect(() => {
|
|
43
|
-
setTimeout(() => input?.focus(), 1)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
44
|
createEffect(() => {
|
|
47
45
|
const q = query().trim()
|
|
48
46
|
setError("")
|
|
49
47
|
if (!q) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
setLoading(true)
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
try {
|
|
51
|
+
setResults(recentSessionMessages({ limit: 40, dbPath: dbPath(), directory }))
|
|
52
|
+
} catch (err) {
|
|
53
|
+
setResults([])
|
|
54
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
55
|
+
}
|
|
56
|
+
setLoading(false)
|
|
57
|
+
setSelected(0)
|
|
58
|
+
}, 1)
|
|
59
|
+
onCleanup(() => clearTimeout(timer))
|
|
57
60
|
return
|
|
58
61
|
}
|
|
62
|
+
setLoading(true)
|
|
59
63
|
setBusy(true)
|
|
60
64
|
const timer = setTimeout(() => {
|
|
61
65
|
try {
|
|
62
|
-
setResults(searchSessionMessages(q, { limit: 120, dbPath, directory }))
|
|
66
|
+
setResults(searchSessionMessages(q, { limit: 120, dbPath: dbPath(), directory }))
|
|
63
67
|
setSelected(0)
|
|
64
68
|
} catch (err) {
|
|
65
69
|
setResults([])
|
|
66
70
|
setError(err instanceof Error ? err.message : String(err))
|
|
67
71
|
} finally {
|
|
68
72
|
setBusy(false)
|
|
73
|
+
setLoading(false)
|
|
69
74
|
}
|
|
70
75
|
}, 180)
|
|
71
76
|
onCleanup(() => clearTimeout(timer))
|
|
@@ -97,7 +102,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
97
102
|
return
|
|
98
103
|
}
|
|
99
104
|
try {
|
|
100
|
-
setPreviewParts(loadConversationWindow(item, { before: 12, after: 24, dbPath }))
|
|
105
|
+
setPreviewParts(loadConversationWindow(item, { before: 12, after: 24, dbPath: dbPath() }))
|
|
101
106
|
} catch {
|
|
102
107
|
setPreviewParts([])
|
|
103
108
|
}
|
|
@@ -124,35 +129,59 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
124
129
|
previewScroll?.scrollBy(direction * previewScrollAmount(previewScroll))
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
const focusInput = () => {
|
|
133
|
+
input?.focus()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const blurInput = () => {
|
|
137
|
+
const el = input as (InputRenderable & { blur?: () => void }) | undefined
|
|
138
|
+
el?.blur?.()
|
|
139
|
+
}
|
|
140
|
+
|
|
127
141
|
useKeyboard((evt) => {
|
|
128
142
|
if (!props.api.ui.dialog.open) return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
props.onClose()
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
if (isKey(evt, "down") || (evt.ctrl && isKey(evt, "j"))) {
|
|
135
|
-
prevent(evt)
|
|
136
|
-
move(1)
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
if (isKey(evt, "up") || (evt.ctrl && isKey(evt, "k"))) {
|
|
140
|
-
prevent(evt)
|
|
141
|
-
move(-1)
|
|
142
|
-
return
|
|
143
|
-
}
|
|
144
|
-
if (isKey(evt, "enter", "return")) {
|
|
143
|
+
|
|
144
|
+
if (isKey(evt, "down") || isKey(evt, "up")) {
|
|
145
145
|
prevent(evt)
|
|
146
|
-
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
|
-
if (evt.ctrl && isKey(evt, "d")) {
|
|
150
|
-
scrollPreview(1, evt)
|
|
146
|
+
isKey(evt, "down") ? move(1) : move(-1)
|
|
151
147
|
return
|
|
152
148
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
149
|
+
|
|
150
|
+
if (mode() === "normal") {
|
|
151
|
+
if (isKey(evt, "j")) {
|
|
152
|
+
prevent(evt)
|
|
153
|
+
move(1)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (isKey(evt, "k")) {
|
|
157
|
+
prevent(evt)
|
|
158
|
+
move(-1)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
if (isKey(evt, "q")) {
|
|
162
|
+
prevent(evt)
|
|
163
|
+
props.onClose()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (isKey(evt, "d")) {
|
|
167
|
+
scrollPreview(1, evt)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (isKey(evt, "u")) {
|
|
171
|
+
scrollPreview(-1, evt)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
if (isKey(evt, "/")) {
|
|
175
|
+
prevent(evt)
|
|
176
|
+
setMode("insert")
|
|
177
|
+
focusInput()
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (isKey(evt, "enter", "return")) {
|
|
181
|
+
prevent(evt)
|
|
182
|
+
open()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
156
185
|
}
|
|
157
186
|
})
|
|
158
187
|
|
|
@@ -183,15 +212,20 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
183
212
|
focusedBackgroundColor={theme().backgroundPanel}
|
|
184
213
|
onInput={(value) => setQuery(value)}
|
|
185
214
|
onKeyDown={(evt: ParsedKey) => {
|
|
186
|
-
if (evt
|
|
187
|
-
|
|
215
|
+
if (isKey(evt, "down") || isKey(evt, "up")) {
|
|
216
|
+
prevent(evt)
|
|
217
|
+
isKey(evt, "down") ? move(1) : move(-1)
|
|
188
218
|
return
|
|
189
219
|
}
|
|
190
|
-
if (evt.ctrl && isKey(evt, "
|
|
220
|
+
if (evt.ctrl && isKey(evt, "q")) {
|
|
221
|
+
prevent(evt)
|
|
222
|
+
setMode("normal")
|
|
223
|
+
blurInput()
|
|
224
|
+
}
|
|
191
225
|
}}
|
|
192
226
|
flexGrow={1}
|
|
193
227
|
/>
|
|
194
|
-
<text fg={theme().textMuted}>{busy() ? "searching" : query().trim() ? `${results().length} hits` : `${results().length} recent`}</text>
|
|
228
|
+
<text fg={theme().textMuted}>{busy() ? "searching" : loading() ? "loading..." : query().trim() ? `${results().length} hits` : `${results().length} recent`}</text>
|
|
195
229
|
</box>
|
|
196
230
|
</box>
|
|
197
231
|
|
|
@@ -207,20 +241,30 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
207
241
|
</box>
|
|
208
242
|
}
|
|
209
243
|
>
|
|
210
|
-
<Show when={
|
|
211
|
-
<
|
|
212
|
-
{(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
244
|
+
<Show when={!loading()}>
|
|
245
|
+
<Show when={results().length > 0} fallback={<EmptyState query={query()} theme={theme()} />}>
|
|
246
|
+
<For each={results()}>
|
|
247
|
+
{(item, index) => (
|
|
248
|
+
<ResultRow
|
|
249
|
+
item={item}
|
|
250
|
+
active={index() === selected()}
|
|
251
|
+
width={leftWidth()}
|
|
252
|
+
query={query()}
|
|
253
|
+
theme={theme()}
|
|
254
|
+
onMouseOver={() => setSelected(index())}
|
|
255
|
+
onOpen={open}
|
|
256
|
+
/>
|
|
257
|
+
)}
|
|
258
|
+
</For>
|
|
259
|
+
</Show>
|
|
260
|
+
</Show>
|
|
261
|
+
<Show when={loading()}>
|
|
262
|
+
<SkeletonRow theme={theme()} />
|
|
263
|
+
<SkeletonRow theme={theme()} />
|
|
264
|
+
<SkeletonRow theme={theme()} />
|
|
265
|
+
<SkeletonRow theme={theme()} />
|
|
266
|
+
<SkeletonRow theme={theme()} />
|
|
267
|
+
<SkeletonRow theme={theme()} />
|
|
224
268
|
</Show>
|
|
225
269
|
</Show>
|
|
226
270
|
</scrollbox>
|
|
@@ -238,16 +282,29 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
|
|
|
238
282
|
</box>
|
|
239
283
|
</box>
|
|
240
284
|
|
|
241
|
-
<
|
|
242
|
-
<box flexDirection="row" gap={2}>
|
|
243
|
-
<text fg={theme().
|
|
244
|
-
<text fg={theme().textMuted}
|
|
245
|
-
|
|
246
|
-
|
|
285
|
+
<Show when={mode() === "normal"}>
|
|
286
|
+
<box paddingLeft={4} paddingRight={4} flexDirection="row" backgroundColor={theme().backgroundElement} gap={2}>
|
|
287
|
+
<text fg={theme().accent}><span style={{ bold: true }}>NORMAL</span></text>
|
|
288
|
+
<text fg={theme().textMuted}>·</text>
|
|
289
|
+
<text fg={theme().text}>j/k move</text>
|
|
290
|
+
<text fg={theme().textMuted}>·</text>
|
|
291
|
+
<text fg={theme().textMuted}>d/u scroll</text>
|
|
292
|
+
<text fg={theme().textMuted}>·</text>
|
|
293
|
+
<text fg={theme().text}>/ search</text>
|
|
294
|
+
<text fg={theme().textMuted}>·</text>
|
|
247
295
|
<text fg={theme().textMuted}>enter open</text>
|
|
248
|
-
<text fg={theme().textMuted}
|
|
296
|
+
<text fg={theme().textMuted}>·</text>
|
|
297
|
+
<text fg={theme().text}>q close</text>
|
|
249
298
|
</box>
|
|
250
|
-
</
|
|
299
|
+
</Show>
|
|
300
|
+
<Show when={mode() === "insert"}>
|
|
301
|
+
<box paddingLeft={4} paddingRight={4} flexDirection="row" backgroundColor={theme().backgroundElement} gap={2}>
|
|
302
|
+
<text fg={theme().warning}><span style={{ bold: true }}>INSERT</span></text>
|
|
303
|
+
<text fg={theme().textMuted}>·</text>
|
|
304
|
+
<text fg={theme().textMuted}>↑/↓ move · ^Q normal</text>
|
|
305
|
+
</box>
|
|
306
|
+
</Show>
|
|
307
|
+
|
|
251
308
|
</box>
|
|
252
309
|
</box>
|
|
253
310
|
</box>
|