@bojackduy/opencode-telescope 0.1.2 → 0.1.4

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
@@ -1,16 +1,30 @@
1
1
  # opencode-telescope
2
2
 
3
- Telescope-style session conversation search for the OpenCode TUI.
3
+ Fuzzy search across all your OpenCode conversations — grep through session and chat history, find code snippets, and jump to any chat instantly.
4
4
 
5
- This plugin searches OpenCode's local SQLite session database directly in read-only mode, then opens the selected session with the existing TUI route.
5
+ > Inspired by [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) a fuzzy finder for your conversation history.
6
+
7
+ ## Use cases
8
+
9
+ - **"I know I discussed this somewhere"** — grep all your sessions by keyword
10
+ - **"Find that code snippet"** — search for code you saw in a past LLM response
11
+ - **"Revisit a decision"** — find the conversation where you chose approach X
12
+ - **"Session journal"** — browse your entire conversation history like a timeline
13
+
14
+ ## Features
15
+
16
+ - **Fuzzy grep** — search across all session messages by text
17
+ - **Live preview** — preview the matched conversation result before opening
18
+ - **Find & jump** — select any result and jump straight to that session
19
+ - **Neovim Telescope-style UX** — familiar `<leader>f` keybind and `/telescope` command
6
20
 
7
21
  ## Installation
8
22
 
9
- Add the plugin to your `opencode.json` (or `tui.json`):
23
+ Add the plugin to your `tui.json`:
10
24
 
11
25
  ```jsonc
12
26
  {
13
- "plugin": ["@bojackduy/opencode-telescope"]
27
+ "plugin": ["@bojackduy/opencode-telescope"],
14
28
  }
15
29
  ```
16
30
 
@@ -22,4 +36,14 @@ Add the plugin to your `opencode.json` (or `tui.json`):
22
36
 
23
37
  ## Usage
24
38
 
25
- Run `/telescope` or shortcut `<lead>+f` in OpenCode to search and jump to any session.
39
+ | Action | Key / Command |
40
+ |--------|--------------|
41
+ | Open search | `<leader>f` or `/telescope` |
42
+ | Type to filter | Fuzzy match against conversation text |
43
+ | Navigate results | `↑` / `↓` or `Ctrl+j` / `Ctrl+k` |
44
+ | Preview | Select a result to see the conversation preview |
45
+ | Open | Press `Enter` to jump to the selected session |
46
+
47
+ ## How it works
48
+
49
+ Reads the OpenCode local SQLite session database in read-only mode, parses conversations into searchable text, and opens the selected session through the existing TUI route.
@@ -228,10 +228,20 @@ function searchResultPreviewPart(item: SearchResult): ConversationPreviewPart {
228
228
  }
229
229
 
230
230
  function conversationMatch(part: ConversationPreviewPart, item: SearchResult) {
231
- if (!part.target) return
232
- const index = part.text.toLowerCase().indexOf(item.match.toLowerCase())
233
- if (index === -1 || !item.match) return
234
- return { start: index, end: index + item.match.length }
231
+ if (!part.target || !item.match) return
232
+ const tokens = item.match.split(/\s+/)
233
+ const lowerText = part.text.toLowerCase()
234
+ let searchPos = 0
235
+ let firstStart = -1
236
+ let lastEnd = -1
237
+ for (const token of tokens) {
238
+ const index = lowerText.indexOf(token.toLowerCase(), searchPos)
239
+ if (index === -1) return
240
+ if (firstStart === -1) firstStart = index
241
+ searchPos = index + token.length
242
+ lastEnd = searchPos
243
+ }
244
+ return { start: firstStart, end: lastEnd }
235
245
  }
236
246
 
237
247
  function conversationMarkdown(part: ConversationPreviewPart, item: SearchResult) {
@@ -243,14 +253,23 @@ function conversationMarkdown(part: ConversationPreviewPart, item: SearchResult)
243
253
  function matchExcerpt(text: string, query: string, radius = 80) {
244
254
  const needle = query.trim()
245
255
  if (!needle) return
246
- const start = text.toLowerCase().indexOf(needle.toLowerCase())
247
- if (start === -1) return
248
- const end = start + needle.length
249
- const beforeStart = Math.max(0, start - radius)
250
- const afterEnd = Math.min(text.length, end + radius)
256
+ const tokens = needle.split(/\s+/)
257
+ const lowerText = text.toLowerCase()
258
+ let searchPos = 0
259
+ let firstStart = -1
260
+ let lastEnd = -1
261
+ for (const token of tokens) {
262
+ const start = lowerText.indexOf(token.toLowerCase(), searchPos)
263
+ if (start === -1) return
264
+ if (firstStart === -1) firstStart = start
265
+ searchPos = start + token.length
266
+ lastEnd = searchPos
267
+ }
268
+ const beforeStart = Math.max(0, firstStart - radius)
269
+ const afterEnd = Math.min(text.length, lastEnd + radius)
251
270
  return {
252
- before: `${beforeStart > 0 ? "..." : ""}${text.slice(beforeStart, start).replace(/\s+/g, " ")}`,
253
- match: text.slice(start, end),
254
- after: `${text.slice(end, afterEnd).replace(/\s+/g, " ")}${afterEnd < text.length ? "..." : ""}`,
271
+ before: `${beforeStart > 0 ? "..." : ""}${text.slice(beforeStart, firstStart).replace(/\s+/g, " ")}`,
272
+ match: text.slice(firstStart, lastEnd),
273
+ after: `${text.slice(lastEnd, afterEnd).replace(/\s+/g, " ")}${afterEnd < text.length ? "..." : ""}`,
255
274
  }
256
275
  }
@@ -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,8 +1,8 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-telescope",
4
- "version": "0.1.2",
5
- "description": "Telescope-style session conversation search for OpenCode TUI",
4
+ "version": "0.1.4",
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",
8
8
  "repository": {
@@ -18,7 +18,16 @@
18
18
  "opencode-plugin",
19
19
  "tui",
20
20
  "telescope",
21
- "search"
21
+ "search",
22
+ "session",
23
+ "conversation",
24
+ "history",
25
+ "fuzzy",
26
+ "fuzzy-finder",
27
+ "grep",
28
+ "finder",
29
+ "neovim",
30
+ "chat-history"
22
31
  ],
23
32
  "scripts": {
24
33
  "typecheck": "bun tsc -p tsconfig.json --noEmit",
package/search.ts CHANGED
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite"
2
2
  import { existsSync, readdirSync } from "node:fs"
3
3
  import { homedir } from "node:os"
4
4
  import path from "node:path"
5
+ import { debug } from "./ui/debug.ts"
5
6
 
6
7
  export type SearchResult = {
7
8
  id: string
@@ -67,20 +68,23 @@ type ConversationRow = {
67
68
  data: string
68
69
  }
69
70
 
71
+ let cachedDbPath: string | undefined
72
+
70
73
  export function resolveDatabasePath() {
74
+ if (cachedDbPath) return cachedDbPath
71
75
  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)
76
+ if (process.env.OPENCODE_DB === ":memory:" || path.isAbsolute(process.env.OPENCODE_DB)) return cachedDbPath = process.env.OPENCODE_DB
77
+ return cachedDbPath = path.join(candidateDataDirs()[0] ?? defaultDataDir(), process.env.OPENCODE_DB)
74
78
  }
75
79
  if (process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || process.env.OPENCODE_DISABLE_CHANNEL_DB === "true") {
76
- return requireExistingDatabase(["opencode.db"])
80
+ return cachedDbPath = requireExistingDatabase(["opencode.db"])
77
81
  }
78
82
  const stable = candidateDatabasePaths(["opencode.db"]).find(existsSync)
79
- if (stable) return stable
83
+ if (stable) return cachedDbPath = stable
80
84
  if (process.env.OPENCODE_CHANNEL) {
81
85
  const channel = process.env.OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
82
86
  const candidate = candidateDatabasePaths([`opencode-${channel}.db`]).find(existsSync)
83
- if (candidate) return candidate
87
+ if (candidate) return cachedDbPath = candidate
84
88
  }
85
89
  const fallback = candidateDatabasePaths(["opencode.db"])[0] ?? path.join(defaultDataDir(), "opencode.db")
86
90
  try {
@@ -91,28 +95,28 @@ export function resolveDatabasePath() {
91
95
  .map((entry) => path.join(dir, entry.name)),
92
96
  )
93
97
  .at(0)
94
- return discovered ?? fallback
98
+ return cachedDbPath = discovered ?? fallback
95
99
  } catch {
96
- return fallback
100
+ return cachedDbPath = fallback
97
101
  }
98
102
  }
99
103
 
100
- export function searchSessionMessages(query: string, options?: { limit?: number; dbPath?: string; directory?: string }) {
104
+ export function searchSessionMessages(query: string, options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
101
105
  const term = query.trim()
102
106
  if (!term) return []
103
107
  if (options?.dbPath === ":memory:") return []
104
108
  const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
105
109
  try {
106
- return searchRows(db, term, options?.limit ?? 80, options?.directory)
110
+ return searchRows(db, term, options?.limit ?? 80, options?.directory, options?.offset)
107
111
  } finally {
108
112
  db.close()
109
113
  }
110
114
  }
111
115
 
112
- export function recentSessionMessages(options?: { limit?: number; dbPath?: string; directory?: string }) {
116
+ export function recentSessionMessages(options?: { limit?: number; offset?: number; dbPath?: string; directory?: string }) {
113
117
  const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
114
118
  try {
115
- return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory).flatMap(
119
+ return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory, options?.offset).flatMap(
116
120
  (row) => rowToSearchResult(row, "") ?? [],
117
121
  )
118
122
  } finally {
@@ -121,34 +125,67 @@ export function recentSessionMessages(options?: { limit?: number; dbPath?: strin
121
125
  }
122
126
 
123
127
  export function loadConversationWindow(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }) {
128
+ debug.time("preview:query:total")
124
129
  const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
125
130
  try {
126
- return db
127
- .query<ConversationRow, [string, string, number, number]>(`
128
- WITH visible AS (
129
- SELECT p.id,
130
- p.message_id,
131
- p.session_id,
132
- json_extract(m.data, '$.role') AS role,
133
- json_extract(p.data, '$.type') AS type,
134
- p.time_created,
135
- p.data,
136
- row_number() OVER (ORDER BY p.time_created ASC, p.id ASC) AS rn
137
- FROM part p
138
- JOIN message m ON m.id = p.message_id
139
- WHERE p.session_id = ?
140
- AND json_extract(p.data, '$.type') IN ('text', 'reasoning', 'tool')
141
- AND json_extract(m.data, '$.role') IN ('user', 'assistant')
142
- ), hit AS (
143
- SELECT rn FROM visible WHERE id = ?
144
- )
145
- SELECT id, message_id, session_id, role, type, time_created, data
146
- FROM visible
147
- WHERE rn BETWEEN (SELECT rn FROM hit) - ? AND (SELECT rn FROM hit) + ?
148
- ORDER BY time_created ASC, id ASC
149
- `)
150
- .all(result.sessionID, result.id, options?.before ?? 12, options?.after ?? 24)
151
- .flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
131
+ const before = options?.before ?? 3
132
+ const after = options?.after ?? 6
133
+
134
+ debug.time("preview:query:hit")
135
+ const hit = db.query<{ time_created: number }, [string]>(
136
+ "SELECT time_created FROM part WHERE id = ?",
137
+ ).get(result.id)
138
+ debug.timeEnd("preview:query:hit")
139
+ if (!hit) {
140
+ debug.timeEnd("preview:query:total")
141
+ return []
142
+ }
143
+
144
+ const fetchBefore = Math.max(before * 4, 30)
145
+ const fetchAfter = Math.max(after * 4, 50)
146
+
147
+ debug.time("preview:query:before")
148
+ const beforeRows = db.query<ConversationRow, [string, number, number, string, number]>(`
149
+ SELECT p.id, p.message_id, p.session_id,
150
+ json_extract(m.data, '$.role') AS role,
151
+ json_extract(p.data, '$.type') AS type,
152
+ p.time_created, p.data
153
+ FROM part p
154
+ JOIN message m ON m.id = p.message_id
155
+ WHERE p.session_id = ?
156
+ AND (p.time_created < ? OR (p.time_created = ? AND p.id < ?))
157
+ ORDER BY p.time_created DESC, p.id DESC
158
+ LIMIT ?
159
+ `).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchBefore)
160
+ debug.timeEnd("preview:query:before")
161
+
162
+ debug.time("preview:query:after")
163
+ const afterRows = db.query<ConversationRow, [string, number, number, string, number]>(`
164
+ SELECT p.id, p.message_id, p.session_id,
165
+ json_extract(m.data, '$.role') AS role,
166
+ json_extract(p.data, '$.type') AS type,
167
+ p.time_created, p.data
168
+ FROM part p
169
+ JOIN message m ON m.id = p.message_id
170
+ WHERE p.session_id = ?
171
+ AND (p.time_created > ? OR (p.time_created = ? AND p.id >= ?))
172
+ ORDER BY p.time_created ASC, p.id ASC
173
+ LIMIT ?
174
+ `).all(result.sessionID, hit.time_created, hit.time_created, result.id, fetchAfter)
175
+ debug.timeEnd("preview:query:after")
176
+
177
+ debug.time("preview:query:parse")
178
+ const isValid = (row: ConversationRow) =>
179
+ (row.role === "user" || row.role === "assistant") &&
180
+ (row.type === "text" || row.type === "reasoning" || row.type === "tool")
181
+
182
+ const parts = [
183
+ ...beforeRows.filter(isValid).slice(0, before).reverse(),
184
+ ...afterRows.filter(isValid).slice(0, after + 1),
185
+ ].flatMap((row) => parseConversationPart(row, row.id === result.id) ?? [])
186
+ debug.timeEnd("preview:query:parse")
187
+ debug.timeEnd("preview:query:total")
188
+ return parts
152
189
  } finally {
153
190
  db.close()
154
191
  }
@@ -193,10 +230,20 @@ export function extractSearchText(data: string) {
193
230
 
194
231
  export function makeSnippet(text: string, query: string, radius = 72) {
195
232
  const haystack = text.replace(/\s+/g, " ").trim()
196
- const index = haystack.toLowerCase().indexOf(query.trim().toLowerCase())
197
- if (index === -1) return truncate(haystack, radius * 2)
198
- const start = Math.max(0, index - radius)
199
- const end = Math.min(haystack.length, index + query.length + radius)
233
+ const tokens = query.trim().split(/\s+/)
234
+ const lower = haystack.toLowerCase()
235
+ let searchPos = 0
236
+ let firstIndex = -1
237
+ let lastEnd = 0
238
+ for (const token of tokens) {
239
+ const index = lower.indexOf(token.toLowerCase(), searchPos)
240
+ if (index === -1) return truncate(haystack, radius * 2)
241
+ if (firstIndex === -1) firstIndex = index
242
+ searchPos = index + token.length
243
+ lastEnd = searchPos
244
+ }
245
+ const start = Math.max(0, firstIndex - radius)
246
+ const end = Math.min(haystack.length, lastEnd + radius)
200
247
  return `${start > 0 ? "..." : ""}${haystack.slice(start, end)}${end < haystack.length ? "..." : ""}`
201
248
  }
202
249
 
@@ -214,15 +261,27 @@ function findMatch(text: string, query: string, radius = 96) {
214
261
  excerpt: collapsed.slice(0, end),
215
262
  }
216
263
  }
217
- const start = text.toLowerCase().indexOf(needle.toLowerCase())
218
- if (start === -1) return
219
- const end = start + needle.length
264
+ const tokens = needle.split(/\s+/)
265
+ const lowerText = text.toLowerCase()
266
+ let searchPos = 0
267
+ let firstStart = -1
268
+ let lastEnd = -1
269
+ for (const token of tokens) {
270
+ const pos = lowerText.indexOf(token.toLowerCase(), searchPos)
271
+ if (pos === -1) return
272
+ if (firstStart === -1) firstStart = pos
273
+ searchPos = pos + token.length
274
+ lastEnd = searchPos
275
+ }
276
+ const start = firstStart
277
+ const end = lastEnd
220
278
  const lineStart = text.lastIndexOf("\n", start - 1) + 1
221
279
  const nextLine = text.indexOf("\n", end)
222
280
  const lineEnd = nextLine === -1 ? text.length : nextLine
223
281
  const line = text.slice(lineStart, lineEnd)
282
+ const matchLen = end - start
224
283
  const lineMatchStart = Math.max(0, start - lineStart)
225
- const lineMatchEnd = lineMatchStart + needle.length
284
+ const lineMatchEnd = lineMatchStart + matchLen
226
285
  const excerptStart = Math.max(0, lineMatchStart - radius)
227
286
  const excerptEnd = Math.min(line.length, lineMatchEnd + radius)
228
287
  const before = normalizeSnippetSegment(line.slice(excerptStart, lineMatchStart))
@@ -358,35 +417,35 @@ function parseToolState(value: unknown): ToolState | undefined {
358
417
  }
359
418
  }
360
419
 
361
- function searchRows(db: Database, query: string, limit: number, directory?: string) {
420
+ function searchRows(db: Database, query: string, limit: number, directory?: string, offset?: number) {
362
421
  if (!tableExists(db, "part") || !tableExists(db, "message")) return []
363
- return visibleTextRows(db, limit, query, directory).flatMap((row) => rowToSearchResult(row, query) ?? [])
422
+ return visibleTextRows(db, limit, query, directory, offset).flatMap((row) => rowToSearchResult(row, query) ?? [])
364
423
  }
365
424
 
366
- function visibleTextRows(db: Database, limit: number, query?: string, directory?: string) {
367
- if (query) {
368
- const input = directory ? [directory, `%${query}%`, limit] satisfies [string, string, number] : [`%${query}%`, limit] satisfies [string, number]
369
- return db
370
- .query<Row, [string, string, number] | [string, number]>(`
371
- SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
372
- json_extract(m.data, '$.role') AS role,
373
- p.time_created,
374
- json_extract(p.data, '$.text') AS text
375
- FROM part p
376
- JOIN message m ON m.id = p.message_id
377
- JOIN session s ON s.id = p.session_id
378
- WHERE json_extract(p.data, '$.type') = 'text'
379
- AND json_extract(m.data, '$.role') IN ('user', 'assistant')
380
- ${directory ? "AND s.directory = ?" : ""}
381
- AND json_extract(p.data, '$.text') LIKE ?
382
- ORDER BY p.time_created DESC
383
- LIMIT ?
384
- `)
385
- .all(...input)
425
+ function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number) {
426
+ const offsetClause = offset ? "OFFSET ?" : ""
427
+ const conditions: string[] = [
428
+ "json_extract(p.data, '$.type') = 'text'",
429
+ "json_extract(m.data, '$.role') IN ('user', 'assistant')",
430
+ ]
431
+ const params: (string | number)[] = []
432
+
433
+ if (directory) {
434
+ conditions.push("s.directory = ?")
435
+ params.push(directory)
386
436
  }
387
- const input = directory ? [directory, limit] satisfies [string, number] : [limit] satisfies [number]
437
+
438
+ const tokens = query ? query.trim().split(/\s+/).filter(Boolean) : []
439
+ for (const token of tokens) {
440
+ conditions.push("json_extract(p.data, '$.text') LIKE ?")
441
+ params.push(`%${token}%`)
442
+ }
443
+
444
+ params.push(limit)
445
+ if (offset) params.push(offset)
446
+
388
447
  return db
389
- .query<Row, [string, number] | [number]>(`
448
+ .query<Row, (string | number)[]>(`
390
449
  SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
391
450
  json_extract(m.data, '$.role') AS role,
392
451
  p.time_created,
@@ -394,13 +453,11 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
394
453
  FROM part p
395
454
  JOIN message m ON m.id = p.message_id
396
455
  JOIN session s ON s.id = p.session_id
397
- WHERE json_extract(p.data, '$.type') = 'text'
398
- AND json_extract(m.data, '$.role') IN ('user', 'assistant')
399
- ${directory ? "AND s.directory = ?" : ""}
456
+ WHERE ${conditions.join(" AND ")}
400
457
  ORDER BY p.time_created DESC
401
- LIMIT ?
458
+ LIMIT ? ${offsetClause}
402
459
  `)
403
- .all(...input)
460
+ .all(...params as any[])
404
461
  }
405
462
 
406
463
  function tableExists(db: Database, name: string) {
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,
@@ -13,6 +13,7 @@ import {
13
13
  type ConversationPreviewPart,
14
14
  type SearchResult,
15
15
  } from "./search.ts"
16
+ import { debug } from "./ui/debug.ts"
16
17
  import { syntaxStyle } from "./ui/format.ts"
17
18
  import { isKey, prevent } from "./ui/keyboard.ts"
18
19
  import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
@@ -25,6 +26,16 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
25
26
  const [selected, setSelected] = createSignal(0)
26
27
  const [busy, setBusy] = createSignal(false)
27
28
  const [error, setError] = createSignal("")
29
+ const [mode, setMode] = createSignal<"normal" | "insert">("normal")
30
+ const [loading, setLoading] = createSignal(true)
31
+ const [hasMore, setHasMore] = createSignal(true)
32
+ const [loadingMore, setLoadingMore] = createSignal(false)
33
+ const BATCH_SIZE = 25
34
+ const RECENT_BATCH_SIZE = 15
35
+ const INITIAL_PREVIEW_BEFORE = 3
36
+ const INITIAL_PREVIEW_AFTER = 6
37
+ const PREVIEW_EXPAND_STEP = 5
38
+ const [previewRange, setPreviewRange] = createSignal({ before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
28
39
  let input: InputRenderable | undefined
29
40
  let resultScroll: ScrollBoxRenderable | undefined
30
41
  let previewScroll: ScrollBoxRenderable | undefined
@@ -36,36 +47,52 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
36
47
  const leftWidth = createMemo(() => Math.max(36, Math.min(64, Math.floor(popupWidth() * 0.36))))
37
48
  const height = createMemo(() => Math.max(18, dimensions().height - 8))
38
49
  const verticalOffset = createMemo(() => Math.floor(dimensions().height / 4 - height() / 2) - 2)
39
- const dbPath = resolveDatabasePath()
50
+ const dbPath = createMemo(() => resolveDatabasePath())
40
51
  const directory = props.api.state.path.directory
41
52
 
42
- createEffect(() => {
43
- setTimeout(() => input?.focus(), 1)
44
- })
45
-
46
53
  createEffect(() => {
47
54
  const q = query().trim()
48
55
  setError("")
56
+ setHasMore(true)
57
+ const db = dbPath()
58
+ const dir = directory
59
+
49
60
  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)
61
+ setLoading(true)
62
+ const timer = setTimeout(() => {
63
+ debug.time("query:recent")
64
+ try {
65
+ const batch = recentSessionMessages({ limit: RECENT_BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
66
+ setResults(batch)
67
+ setHasMore(batch.length >= RECENT_BATCH_SIZE)
68
+ setSelected(0)
69
+ } catch (err) {
70
+ setResults([])
71
+ setError(err instanceof Error ? err.message : String(err))
72
+ }
73
+ debug.timeEnd("query:recent")
74
+ setLoading(false)
75
+ }, 1)
76
+ onCleanup(() => clearTimeout(timer))
57
77
  return
58
78
  }
79
+
80
+ setLoading(true)
59
81
  setBusy(true)
60
82
  const timer = setTimeout(() => {
83
+ debug.time("query:search")
61
84
  try {
62
- setResults(searchSessionMessages(q, { limit: 120, dbPath, directory }))
85
+ const batch = searchSessionMessages(q, { limit: BATCH_SIZE, offset: 0, dbPath: db, directory: dir })
86
+ setResults(batch)
87
+ setHasMore(batch.length >= BATCH_SIZE)
63
88
  setSelected(0)
64
89
  } catch (err) {
65
90
  setResults([])
66
91
  setError(err instanceof Error ? err.message : String(err))
67
92
  } finally {
93
+ debug.timeEnd("query:search")
68
94
  setBusy(false)
95
+ setLoading(false)
69
96
  }
70
97
  }, 180)
71
98
  onCleanup(() => clearTimeout(timer))
@@ -73,6 +100,7 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
73
100
 
74
101
  const move = (delta: number) => {
75
102
  if (results().length === 0) return
103
+ debug.time("nav:total")
76
104
  setSelected((index) => (index + delta + results().length) % results().length)
77
105
  }
78
106
 
@@ -90,24 +118,82 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
90
118
  }
91
119
  })
92
120
 
121
+ createEffect(() => {
122
+ const index = selected()
123
+ const total = results().length
124
+ if (index < total - 3) return
125
+ if (!hasMore() || loadingMore() || busy() || loading()) return
126
+
127
+ const q = query().trim()
128
+ const db = dbPath()
129
+ const dir = directory
130
+
131
+ setLoadingMore(true)
132
+ const timer = setTimeout(() => {
133
+ debug.time("query:load-more")
134
+ try {
135
+ const batch = q
136
+ ? searchSessionMessages(q, { limit: BATCH_SIZE, offset: total, dbPath: db, directory: dir })
137
+ : recentSessionMessages({ limit: RECENT_BATCH_SIZE, offset: total, dbPath: db, directory: dir })
138
+ setResults((prev) => [...prev, ...batch])
139
+ if (batch.length < (q ? BATCH_SIZE : RECENT_BATCH_SIZE)) setHasMore(false)
140
+ } catch {
141
+ // keep existing results on error
142
+ } finally {
143
+ debug.timeEnd("query:load-more")
144
+ setLoadingMore(false)
145
+ }
146
+ }, 100)
147
+ onCleanup(() => clearTimeout(timer))
148
+ })
149
+
150
+ let lastPreviewItemId = ""
93
151
  createEffect(() => {
94
152
  const item = selectedResult()
153
+ const range = previewRange()
95
154
  if (!item) {
96
155
  setPreviewParts([])
97
156
  return
98
157
  }
99
- try {
100
- setPreviewParts(loadConversationWindow(item, { before: 12, after: 24, dbPath }))
101
- } catch {
102
- setPreviewParts([])
158
+ if (item.id !== lastPreviewItemId) {
159
+ lastPreviewItemId = item.id
160
+ debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
161
+ setPreviewRange({ before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
162
+ return
103
163
  }
164
+ const db = dbPath()
165
+ debug.time("preview:load")
166
+ try {
167
+ setPreviewParts(loadConversationWindow(item, { before: range.before, after: range.after, dbPath: db }))
168
+ } catch {}
169
+ debug.timeEnd("nav:total")
170
+ debug.timeEnd("preview:load")
171
+ })
172
+
173
+ createEffect(() => {
174
+ const item = selectedResult()
175
+ if (!item) return
176
+ const interval = setInterval(() => {
177
+ const scroll = previewScroll
178
+ const children = scroll?.getChildren()
179
+ if (!scroll || !children || children.length === 0) return
180
+ const range = previewRange()
181
+ const lastChild = children[children.length - 1] as { y: number; height: number }
182
+ const totalContentHeight = lastChild.y + lastChild.height
183
+ const atTop = scroll.y <= 0 && range.before > 0
184
+ const atBottom = scroll.y + scroll.height >= totalContentHeight - 1
185
+ if (atTop) setPreviewRange((prev) => ({ ...prev, before: prev.before + PREVIEW_EXPAND_STEP }))
186
+ if (atBottom) setPreviewRange((prev) => ({ ...prev, after: prev.after + PREVIEW_EXPAND_STEP }))
187
+ }, 400)
188
+ onCleanup(() => clearInterval(interval))
104
189
  })
105
190
 
106
191
  createEffect(() => {
107
192
  const item = selectedResult()
108
193
  previewParts()
109
194
  if (!item) return
110
- setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
195
+ const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
196
+ onCleanup(() => clearTimeout(timer))
111
197
  })
112
198
 
113
199
  const open = () => {
@@ -124,35 +210,59 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
124
210
  previewScroll?.scrollBy(direction * previewScrollAmount(previewScroll))
125
211
  }
126
212
 
213
+ const focusInput = () => {
214
+ input?.focus()
215
+ }
216
+
217
+ const blurInput = () => {
218
+ const el = input as (InputRenderable & { blur?: () => void }) | undefined
219
+ el?.blur?.()
220
+ }
221
+
127
222
  useKeyboard((evt) => {
128
223
  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")) {
224
+
225
+ if (isKey(evt, "down") || isKey(evt, "up")) {
145
226
  prevent(evt)
146
- open()
147
- return
148
- }
149
- if (evt.ctrl && isKey(evt, "d")) {
150
- scrollPreview(1, evt)
227
+ isKey(evt, "down") ? move(1) : move(-1)
151
228
  return
152
229
  }
153
- if (evt.ctrl && isKey(evt, "u")) {
154
- scrollPreview(-1, evt)
155
- return
230
+
231
+ if (mode() === "normal") {
232
+ if (isKey(evt, "j")) {
233
+ prevent(evt)
234
+ move(1)
235
+ return
236
+ }
237
+ if (isKey(evt, "k")) {
238
+ prevent(evt)
239
+ move(-1)
240
+ return
241
+ }
242
+ if (isKey(evt, "q")) {
243
+ prevent(evt)
244
+ props.onClose()
245
+ return
246
+ }
247
+ if (isKey(evt, "d")) {
248
+ scrollPreview(1, evt)
249
+ return
250
+ }
251
+ if (isKey(evt, "u")) {
252
+ scrollPreview(-1, evt)
253
+ return
254
+ }
255
+ if (isKey(evt, "/")) {
256
+ prevent(evt)
257
+ setMode("insert")
258
+ focusInput()
259
+ return
260
+ }
261
+ if (isKey(evt, "enter", "return")) {
262
+ prevent(evt)
263
+ open()
264
+ return
265
+ }
156
266
  }
157
267
  })
158
268
 
@@ -183,15 +293,20 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
183
293
  focusedBackgroundColor={theme().backgroundPanel}
184
294
  onInput={(value) => setQuery(value)}
185
295
  onKeyDown={(evt: ParsedKey) => {
186
- if (evt.ctrl && isKey(evt, "d")) {
187
- scrollPreview(1, evt)
296
+ if (isKey(evt, "down") || isKey(evt, "up")) {
297
+ prevent(evt)
298
+ isKey(evt, "down") ? move(1) : move(-1)
188
299
  return
189
300
  }
190
- if (evt.ctrl && isKey(evt, "u")) scrollPreview(-1, evt)
301
+ if (evt.ctrl && isKey(evt, "q")) {
302
+ prevent(evt)
303
+ setMode("normal")
304
+ blurInput()
305
+ }
191
306
  }}
192
307
  flexGrow={1}
193
308
  />
194
- <text fg={theme().textMuted}>{busy() ? "searching" : query().trim() ? `${results().length} hits` : `${results().length} recent`}</text>
309
+ <text fg={theme().textMuted}>{busy() ? "searching" : loading() ? "loading..." : query().trim() ? `${results().length} hits` : `${results().length} recent`}</text>
195
310
  </box>
196
311
  </box>
197
312
 
@@ -207,20 +322,36 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
207
322
  </box>
208
323
  }
209
324
  >
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>
325
+ <Show when={!loading()}>
326
+ <Show when={results().length > 0} fallback={<EmptyState query={query()} theme={theme()} />}>
327
+ <For each={results()}>
328
+ {(item, index) => (
329
+ <ResultRow
330
+ item={item}
331
+ active={index() === selected()}
332
+ width={leftWidth()}
333
+ query={query()}
334
+ theme={theme()}
335
+ onMouseOver={() => setSelected(index())}
336
+ onOpen={open}
337
+ />
338
+ )}
339
+ </For>
340
+ <Show when={loadingMore()}>
341
+ <SkeletonRow theme={theme()} />
342
+ </Show>
343
+ <Show when={!hasMore() && results().length > 0}>
344
+ <text fg={theme().textMuted}> no more results</text>
345
+ </Show>
346
+ </Show>
347
+ </Show>
348
+ <Show when={loading()}>
349
+ <SkeletonRow theme={theme()} />
350
+ <SkeletonRow theme={theme()} />
351
+ <SkeletonRow theme={theme()} />
352
+ <SkeletonRow theme={theme()} />
353
+ <SkeletonRow theme={theme()} />
354
+ <SkeletonRow theme={theme()} />
224
355
  </Show>
225
356
  </Show>
226
357
  </scrollbox>
@@ -238,16 +369,29 @@ export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) =>
238
369
  </box>
239
370
  </box>
240
371
 
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}>
372
+ <Show when={mode() === "normal"}>
373
+ <box paddingLeft={4} paddingRight={4} flexDirection="row" backgroundColor={theme().backgroundElement} gap={2}>
374
+ <text fg={theme().accent}><span style={{ bold: true }}>NORMAL</span></text>
375
+ <text fg={theme().textMuted}>·</text>
376
+ <text fg={theme().text}>j/k move</text>
377
+ <text fg={theme().textMuted}>·</text>
378
+ <text fg={theme().textMuted}>d/u scroll</text>
379
+ <text fg={theme().textMuted}>·</text>
380
+ <text fg={theme().text}>/ search</text>
381
+ <text fg={theme().textMuted}>·</text>
247
382
  <text fg={theme().textMuted}>enter open</text>
248
- <text fg={theme().textMuted}>esc close</text>
383
+ <text fg={theme().textMuted}>·</text>
384
+ <text fg={theme().text}>q close</text>
249
385
  </box>
250
- </box>
386
+ </Show>
387
+ <Show when={mode() === "insert"}>
388
+ <box paddingLeft={4} paddingRight={4} flexDirection="row" backgroundColor={theme().backgroundElement} gap={2}>
389
+ <text fg={theme().warning}><span style={{ bold: true }}>INSERT</span></text>
390
+ <text fg={theme().textMuted}>·</text>
391
+ <text fg={theme().textMuted}>↑/↓ move · ^Q normal</text>
392
+ </box>
393
+ </Show>
394
+
251
395
  </box>
252
396
  </box>
253
397
  </box>
package/tsconfig.json CHANGED
@@ -13,5 +13,6 @@
13
13
  "skipLibCheck": true,
14
14
  "types": ["bun"]
15
15
  },
16
- "include": ["**/*.ts", "**/*.tsx"]
16
+ "include": ["**/*.ts", "**/*.tsx"],
17
+ "exclude": ["opencode-smart-session-picker/**"]
17
18
  }
package/ui/debug.ts ADDED
@@ -0,0 +1,25 @@
1
+ const enabled = process.env.OPENCODE_TELESCOPE_DEBUG === "1"
2
+ const timers = new Map<string, number>()
3
+
4
+ export const debug = {
5
+ get enabled() {
6
+ return enabled
7
+ },
8
+
9
+ time(label: string) {
10
+ if (enabled) timers.set(label, performance.now())
11
+ },
12
+
13
+ timeEnd(label: string) {
14
+ if (!enabled) return
15
+ const start = timers.get(label)
16
+ if (start !== undefined) {
17
+ console.log(`[telescope:time] ${label} ${(performance.now() - start).toFixed(2)}ms`)
18
+ timers.delete(label)
19
+ }
20
+ },
21
+
22
+ log(...args: unknown[]) {
23
+ if (enabled) console.log("[telescope]", ...args)
24
+ },
25
+ }