@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 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 `opencode.json` (or `tui.json`):
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.2",
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
- try {
51
- setResults(recentSessionMessages({ limit: 40, dbPath, directory }))
52
- } catch (err) {
53
- setResults([])
54
- setError(err instanceof Error ? err.message : String(err))
55
- }
56
- setSelected(0)
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
- if (isKey(evt, "escape", "esc") || (evt.ctrl && isKey(evt, "c"))) {
130
- prevent(evt)
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
- open()
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
- if (evt.ctrl && isKey(evt, "u")) {
154
- scrollPreview(-1, evt)
155
- return
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.ctrl && isKey(evt, "d")) {
187
- scrollPreview(1, evt)
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, "u")) scrollPreview(-1, 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={results().length > 0} fallback={<EmptyState query={query()} theme={theme()} />}>
211
- <For each={results()}>
212
- {(item, index) => (
213
- <ResultRow
214
- item={item}
215
- active={index() === selected()}
216
- width={leftWidth()}
217
- query={query()}
218
- theme={theme()}
219
- onMouseOver={() => setSelected(index())}
220
- onOpen={open}
221
- />
222
- )}
223
- </For>
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
- <box paddingLeft={4} paddingRight={4} flexDirection="row" justifyContent="space-between" backgroundColor={theme().backgroundElement}>
242
- <box flexDirection="row" gap={2}>
243
- <text fg={theme().textMuted}>^J/^K move</text>
244
- <text fg={theme().textMuted}>^D/^U preview</text>
245
- </box>
246
- <box flexDirection="row" gap={2}>
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}>esc close</text>
296
+ <text fg={theme().textMuted}>·</text>
297
+ <text fg={theme().text}>q close</text>
249
298
  </box>
250
- </box>
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>