@bojackduy/opencode-telescope 0.1.2
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/LICENSE +21 -0
- package/README.md +25 -0
- package/components/preview.tsx +256 -0
- package/components/result-list.tsx +82 -0
- package/package.json +63 -0
- package/search.ts +448 -0
- package/telescope.tsx +256 -0
- package/tsconfig.json +17 -0
- package/tui.tsx +46 -0
- package/ui/format.ts +106 -0
- package/ui/keyboard.ts +14 -0
- package/ui/render-target.ts +76 -0
package/search.ts
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
export type SearchResult = {
|
|
7
|
+
id: string
|
|
8
|
+
messageID: string
|
|
9
|
+
sessionID: string
|
|
10
|
+
sessionTitle: string
|
|
11
|
+
directory: string
|
|
12
|
+
role: "user" | "assistant"
|
|
13
|
+
timeCreated: number
|
|
14
|
+
snippet: string
|
|
15
|
+
matchStart: number
|
|
16
|
+
matchEnd: number
|
|
17
|
+
before: string
|
|
18
|
+
match: string
|
|
19
|
+
after: string
|
|
20
|
+
excerpt: string
|
|
21
|
+
previewBefore: string
|
|
22
|
+
previewMatch: string
|
|
23
|
+
previewAfter: string
|
|
24
|
+
previewMode: "markdown" | "text"
|
|
25
|
+
previewHighlight: boolean
|
|
26
|
+
text: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ConversationPreviewPart = {
|
|
30
|
+
id: string
|
|
31
|
+
messageID: string
|
|
32
|
+
sessionID: string
|
|
33
|
+
role: "user" | "assistant"
|
|
34
|
+
type: "text" | "reasoning" | "tool"
|
|
35
|
+
timeCreated: number
|
|
36
|
+
text: string
|
|
37
|
+
tool?: string
|
|
38
|
+
state?: ToolState
|
|
39
|
+
target: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ToolState = {
|
|
43
|
+
status: "pending" | "running" | "completed" | "error"
|
|
44
|
+
input?: unknown
|
|
45
|
+
output?: string
|
|
46
|
+
error?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Row = {
|
|
50
|
+
id: string
|
|
51
|
+
message_id: string
|
|
52
|
+
session_id: string
|
|
53
|
+
session_title: string | null
|
|
54
|
+
directory: string
|
|
55
|
+
role: "user" | "assistant"
|
|
56
|
+
time_created: number
|
|
57
|
+
text: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type ConversationRow = {
|
|
61
|
+
id: string
|
|
62
|
+
message_id: string
|
|
63
|
+
session_id: string
|
|
64
|
+
role: "user" | "assistant"
|
|
65
|
+
type: "text" | "reasoning" | "tool"
|
|
66
|
+
time_created: number
|
|
67
|
+
data: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resolveDatabasePath() {
|
|
71
|
+
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)
|
|
74
|
+
}
|
|
75
|
+
if (process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || process.env.OPENCODE_DISABLE_CHANNEL_DB === "true") {
|
|
76
|
+
return requireExistingDatabase(["opencode.db"])
|
|
77
|
+
}
|
|
78
|
+
const stable = candidateDatabasePaths(["opencode.db"]).find(existsSync)
|
|
79
|
+
if (stable) return stable
|
|
80
|
+
if (process.env.OPENCODE_CHANNEL) {
|
|
81
|
+
const channel = process.env.OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
82
|
+
const candidate = candidateDatabasePaths([`opencode-${channel}.db`]).find(existsSync)
|
|
83
|
+
if (candidate) return candidate
|
|
84
|
+
}
|
|
85
|
+
const fallback = candidateDatabasePaths(["opencode.db"])[0] ?? path.join(defaultDataDir(), "opencode.db")
|
|
86
|
+
try {
|
|
87
|
+
const discovered = candidateDataDirs()
|
|
88
|
+
.flatMap((dir) =>
|
|
89
|
+
readdirSync(dir, { withFileTypes: true })
|
|
90
|
+
.filter((entry) => entry.isFile() && /^opencode-.+\.db$/.test(entry.name))
|
|
91
|
+
.map((entry) => path.join(dir, entry.name)),
|
|
92
|
+
)
|
|
93
|
+
.at(0)
|
|
94
|
+
return discovered ?? fallback
|
|
95
|
+
} catch {
|
|
96
|
+
return fallback
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function searchSessionMessages(query: string, options?: { limit?: number; dbPath?: string; directory?: string }) {
|
|
101
|
+
const term = query.trim()
|
|
102
|
+
if (!term) return []
|
|
103
|
+
if (options?.dbPath === ":memory:") return []
|
|
104
|
+
const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
|
|
105
|
+
try {
|
|
106
|
+
return searchRows(db, term, options?.limit ?? 80, options?.directory)
|
|
107
|
+
} finally {
|
|
108
|
+
db.close()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function recentSessionMessages(options?: { limit?: number; dbPath?: string; directory?: string }) {
|
|
113
|
+
const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
|
|
114
|
+
try {
|
|
115
|
+
return visibleTextRows(db, options?.limit ?? 40, undefined, options?.directory).flatMap(
|
|
116
|
+
(row) => rowToSearchResult(row, "") ?? [],
|
|
117
|
+
)
|
|
118
|
+
} finally {
|
|
119
|
+
db.close()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function loadConversationWindow(result: SearchResult, options?: { before?: number; after?: number; dbPath?: string }) {
|
|
124
|
+
const db = new Database(options?.dbPath ?? resolveDatabasePath(), { readonly: true })
|
|
125
|
+
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) ?? [])
|
|
152
|
+
} finally {
|
|
153
|
+
db.close()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function rowToSearchResult(row: Row, query: string): SearchResult | undefined {
|
|
158
|
+
const text = row.text.trim()
|
|
159
|
+
const match = findMatch(text, query)
|
|
160
|
+
if (!match) return
|
|
161
|
+
const preview = focusedPreview(text, match.start, match.end)
|
|
162
|
+
return {
|
|
163
|
+
id: row.id,
|
|
164
|
+
messageID: row.message_id,
|
|
165
|
+
sessionID: row.session_id,
|
|
166
|
+
sessionTitle: row.session_title || "Untitled session",
|
|
167
|
+
directory: row.directory,
|
|
168
|
+
role: row.role,
|
|
169
|
+
timeCreated: row.time_created,
|
|
170
|
+
snippet: makeSnippet(text, query),
|
|
171
|
+
matchStart: match.start,
|
|
172
|
+
matchEnd: match.end,
|
|
173
|
+
before: match.before,
|
|
174
|
+
match: match.match,
|
|
175
|
+
after: match.after,
|
|
176
|
+
excerpt: match.excerpt,
|
|
177
|
+
previewBefore: preview.before,
|
|
178
|
+
previewMatch: preview.match,
|
|
179
|
+
previewAfter: preview.after,
|
|
180
|
+
previewMode: preview.mode,
|
|
181
|
+
previewHighlight: preview.highlight,
|
|
182
|
+
text,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function extractSearchText(data: string) {
|
|
187
|
+
try {
|
|
188
|
+
return extractFromValue(JSON.parse(data)).replace(/\s+/g, " ").trim()
|
|
189
|
+
} catch {
|
|
190
|
+
return data.replace(/\s+/g, " ").trim()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function makeSnippet(text: string, query: string, radius = 72) {
|
|
195
|
+
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)
|
|
200
|
+
return `${start > 0 ? "..." : ""}${haystack.slice(start, end)}${end < haystack.length ? "..." : ""}`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function findMatch(text: string, query: string, radius = 96) {
|
|
204
|
+
const needle = query.trim()
|
|
205
|
+
if (!needle) {
|
|
206
|
+
const collapsed = text.replace(/\s+/g, " ").trim()
|
|
207
|
+
const end = Math.min(collapsed.length, radius * 2)
|
|
208
|
+
return {
|
|
209
|
+
start: 0,
|
|
210
|
+
end: 0,
|
|
211
|
+
before: "",
|
|
212
|
+
match: "",
|
|
213
|
+
after: collapsed.slice(0, end),
|
|
214
|
+
excerpt: collapsed.slice(0, end),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const start = text.toLowerCase().indexOf(needle.toLowerCase())
|
|
218
|
+
if (start === -1) return
|
|
219
|
+
const end = start + needle.length
|
|
220
|
+
const lineStart = text.lastIndexOf("\n", start - 1) + 1
|
|
221
|
+
const nextLine = text.indexOf("\n", end)
|
|
222
|
+
const lineEnd = nextLine === -1 ? text.length : nextLine
|
|
223
|
+
const line = text.slice(lineStart, lineEnd)
|
|
224
|
+
const lineMatchStart = Math.max(0, start - lineStart)
|
|
225
|
+
const lineMatchEnd = lineMatchStart + needle.length
|
|
226
|
+
const excerptStart = Math.max(0, lineMatchStart - radius)
|
|
227
|
+
const excerptEnd = Math.min(line.length, lineMatchEnd + radius)
|
|
228
|
+
const before = normalizeSnippetSegment(line.slice(excerptStart, lineMatchStart))
|
|
229
|
+
const after = normalizeSnippetSegment(line.slice(lineMatchEnd, excerptEnd))
|
|
230
|
+
return {
|
|
231
|
+
start,
|
|
232
|
+
end,
|
|
233
|
+
before: `${excerptStart > 0 ? "..." : ""}${before}`,
|
|
234
|
+
match: text.slice(start, end),
|
|
235
|
+
after: `${after}${excerptEnd < line.length ? "..." : ""}`,
|
|
236
|
+
excerpt: `${excerptStart > 0 ? "..." : ""}${normalizeSnippetSegment(line.slice(excerptStart, excerptEnd))}${excerptEnd < line.length ? "..." : ""}`,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function normalizeSnippetSegment(value: string) {
|
|
241
|
+
return value.replace(/\s+/g, " ")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function focusedPreview(text: string, matchStart: number, matchEnd: number) {
|
|
245
|
+
if (matchStart === matchEnd) {
|
|
246
|
+
const preview = text.slice(0, Math.min(text.length, 1400))
|
|
247
|
+
return { before: preview, match: "", after: "", mode: "markdown" as const, highlight: false }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const lineStart = text.lastIndexOf("\n", matchStart - 1) + 1
|
|
251
|
+
const nextLine = text.indexOf("\n", matchEnd)
|
|
252
|
+
const lineEnd = nextLine === -1 ? text.length : nextLine
|
|
253
|
+
const line = text.slice(lineStart, lineEnd)
|
|
254
|
+
|
|
255
|
+
if (line.length > 260) {
|
|
256
|
+
const beforeStart = Math.max(0, matchStart - 180)
|
|
257
|
+
const afterEnd = Math.min(text.length, matchEnd + 220)
|
|
258
|
+
return {
|
|
259
|
+
before: `${beforeStart > 0 ? "..." : ""}${text.slice(beforeStart, matchStart)}`,
|
|
260
|
+
match: text.slice(matchStart, matchEnd),
|
|
261
|
+
after: `${text.slice(matchEnd, afterEnd)}${afterEnd < text.length ? "..." : ""}`,
|
|
262
|
+
mode: "text" as const,
|
|
263
|
+
highlight: true,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const window = lineWindow(text, matchStart, matchEnd, 40, 80)
|
|
268
|
+
const windowText = text.slice(window.start, window.end)
|
|
269
|
+
const relativeStart = matchStart - window.start
|
|
270
|
+
const relativeEnd = matchEnd - window.start
|
|
271
|
+
|
|
272
|
+
const insideCodeFence = isInsideCodeFence(text, matchStart)
|
|
273
|
+
return {
|
|
274
|
+
before: `${window.start > 0 ? "...\n" : ""}${windowText.slice(0, relativeStart)}`,
|
|
275
|
+
match: windowText.slice(relativeStart, relativeEnd),
|
|
276
|
+
after: `${windowText.slice(relativeEnd)}${window.end < text.length ? "\n..." : ""}`,
|
|
277
|
+
mode: "markdown" as const,
|
|
278
|
+
highlight: !insideCodeFence,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function lineWindow(text: string, matchStart: number, matchEnd: number, before: number, after: number) {
|
|
283
|
+
const starts = [0]
|
|
284
|
+
for (let index = text.indexOf("\n"); index !== -1; index = text.indexOf("\n", index + 1)) {
|
|
285
|
+
starts.push(index + 1)
|
|
286
|
+
}
|
|
287
|
+
const matchLine = findLastStartIndex(starts, matchStart)
|
|
288
|
+
const startLine = Math.max(0, matchLine - before)
|
|
289
|
+
const endLine = Math.min(starts.length - 1, matchLine + after)
|
|
290
|
+
const start = starts[startLine] ?? 0
|
|
291
|
+
const end = starts[endLine + 1] ? starts[endLine + 1] - 1 : text.length
|
|
292
|
+
return { start, end: Math.max(end, matchEnd) }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function findLastStartIndex(starts: number[], offset: number) {
|
|
296
|
+
for (let index = starts.length - 1; index >= 0; index--) {
|
|
297
|
+
if (starts[index]! <= offset) return index
|
|
298
|
+
}
|
|
299
|
+
return 0
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isInsideCodeFence(text: string, offset: number) {
|
|
303
|
+
const before = text.slice(0, offset)
|
|
304
|
+
const fences = before.match(/^```/gm)
|
|
305
|
+
return Boolean(fences && fences.length % 2 === 1)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function parseConversationPart(row: ConversationRow, target: boolean): ConversationPreviewPart | undefined {
|
|
309
|
+
const data = parsePartData(row.data)
|
|
310
|
+
if (!data) return
|
|
311
|
+
if (row.type === "tool") {
|
|
312
|
+
return {
|
|
313
|
+
id: row.id,
|
|
314
|
+
messageID: row.message_id,
|
|
315
|
+
sessionID: row.session_id,
|
|
316
|
+
role: row.role,
|
|
317
|
+
type: row.type,
|
|
318
|
+
timeCreated: row.time_created,
|
|
319
|
+
text: "",
|
|
320
|
+
tool: typeof data.tool === "string" ? data.tool : "tool",
|
|
321
|
+
state: parseToolState(data.state),
|
|
322
|
+
target,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const text = typeof data.text === "string" ? data.text.trim() : ""
|
|
326
|
+
if (!text) return
|
|
327
|
+
return {
|
|
328
|
+
id: row.id,
|
|
329
|
+
messageID: row.message_id,
|
|
330
|
+
sessionID: row.session_id,
|
|
331
|
+
role: row.role,
|
|
332
|
+
type: row.type,
|
|
333
|
+
timeCreated: row.time_created,
|
|
334
|
+
text,
|
|
335
|
+
target,
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function parsePartData(data: string) {
|
|
340
|
+
try {
|
|
341
|
+
const value = JSON.parse(data) as unknown
|
|
342
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
|
343
|
+
return value as Record<string, unknown>
|
|
344
|
+
} catch {
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function parseToolState(value: unknown): ToolState | undefined {
|
|
350
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
|
351
|
+
const state = value as Record<string, unknown>
|
|
352
|
+
if (!["pending", "running", "completed", "error"].includes(String(state.status))) return
|
|
353
|
+
return {
|
|
354
|
+
status: state.status as ToolState["status"],
|
|
355
|
+
input: state.input,
|
|
356
|
+
output: typeof state.output === "string" ? state.output : undefined,
|
|
357
|
+
error: typeof state.error === "string" ? state.error : undefined,
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function searchRows(db: Database, query: string, limit: number, directory?: string) {
|
|
362
|
+
if (!tableExists(db, "part") || !tableExists(db, "message")) return []
|
|
363
|
+
return visibleTextRows(db, limit, query, directory).flatMap((row) => rowToSearchResult(row, query) ?? [])
|
|
364
|
+
}
|
|
365
|
+
|
|
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)
|
|
386
|
+
}
|
|
387
|
+
const input = directory ? [directory, limit] satisfies [string, number] : [limit] satisfies [number]
|
|
388
|
+
return db
|
|
389
|
+
.query<Row, [string, number] | [number]>(`
|
|
390
|
+
SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
|
|
391
|
+
json_extract(m.data, '$.role') AS role,
|
|
392
|
+
p.time_created,
|
|
393
|
+
json_extract(p.data, '$.text') AS text
|
|
394
|
+
FROM part p
|
|
395
|
+
JOIN message m ON m.id = p.message_id
|
|
396
|
+
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 = ?" : ""}
|
|
400
|
+
ORDER BY p.time_created DESC
|
|
401
|
+
LIMIT ?
|
|
402
|
+
`)
|
|
403
|
+
.all(...input)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function tableExists(db: Database, name: string) {
|
|
407
|
+
return Boolean(db.query<{ name: string }, [string]>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function defaultDataDir() {
|
|
411
|
+
if (process.env.XDG_DATA_HOME) return path.join(process.env.XDG_DATA_HOME, "opencode")
|
|
412
|
+
return path.join(homedir(), ".local", "share", "opencode")
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function candidateDataDirs() {
|
|
416
|
+
return [
|
|
417
|
+
defaultDataDir(),
|
|
418
|
+
path.join(homedir(), ".local", "share", "opencode"),
|
|
419
|
+
process.platform === "darwin" ? path.join(homedir(), "Library", "Application Support", "opencode") : undefined,
|
|
420
|
+
process.platform === "win32" ? path.join(process.env.APPDATA ?? path.join(homedir(), "AppData", "Roaming"), "opencode") : undefined,
|
|
421
|
+
].filter((item, index, list): item is string => Boolean(item) && list.indexOf(item) === index)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function candidateDatabasePaths(names: string[]) {
|
|
425
|
+
return candidateDataDirs().flatMap((dir) => names.map((name) => path.join(dir, name)))
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function requireExistingDatabase(names: string[]) {
|
|
429
|
+
return candidateDatabasePaths(names).find(existsSync) ?? candidateDatabasePaths(names)[0]!
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function extractFromValue(value: unknown): string {
|
|
433
|
+
if (typeof value === "string") return value
|
|
434
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
|
435
|
+
if (!value) return ""
|
|
436
|
+
if (Array.isArray(value)) return value.map(extractFromValue).filter(Boolean).join("\n")
|
|
437
|
+
if (typeof value !== "object") return ""
|
|
438
|
+
return Object.entries(value)
|
|
439
|
+
.filter(([key]) => !["id", "sessionID", "messageID", "time", "timeCreated", "timeUpdated", "tokens", "cost"].includes(key))
|
|
440
|
+
.map(([, item]) => extractFromValue(item))
|
|
441
|
+
.filter(Boolean)
|
|
442
|
+
.join("\n")
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function truncate(value: string, length: number) {
|
|
446
|
+
if (value.length <= length) return value
|
|
447
|
+
return `${value.slice(0, length - 3)}...`
|
|
448
|
+
}
|
package/telescope.tsx
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import type { InputRenderable, ParsedKey, ScrollBoxRenderable } from "@opentui/core"
|
|
4
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
|
5
|
+
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
|
6
|
+
import { ConversationPreview, PreviewHeader } from "./components/preview.tsx"
|
|
7
|
+
import { EmptyState, ResultRow } from "./components/result-list.tsx"
|
|
8
|
+
import {
|
|
9
|
+
loadConversationWindow,
|
|
10
|
+
recentSessionMessages,
|
|
11
|
+
resolveDatabasePath,
|
|
12
|
+
searchSessionMessages,
|
|
13
|
+
type ConversationPreviewPart,
|
|
14
|
+
type SearchResult,
|
|
15
|
+
} from "./search.ts"
|
|
16
|
+
import { syntaxStyle } from "./ui/format.ts"
|
|
17
|
+
import { isKey, prevent } from "./ui/keyboard.ts"
|
|
18
|
+
import { jumpToRenderedTarget, messageTargetID, previewScrollAmount, scrollPreviewToTarget } from "./ui/render-target.ts"
|
|
19
|
+
|
|
20
|
+
export const Telescope = (props: { api: TuiPluginApi; onClose: () => void }) => {
|
|
21
|
+
const dimensions = useTerminalDimensions()
|
|
22
|
+
const [query, setQuery] = createSignal("")
|
|
23
|
+
const [results, setResults] = createSignal<SearchResult[]>([])
|
|
24
|
+
const [previewParts, setPreviewParts] = createSignal<ConversationPreviewPart[]>([])
|
|
25
|
+
const [selected, setSelected] = createSignal(0)
|
|
26
|
+
const [busy, setBusy] = createSignal(false)
|
|
27
|
+
const [error, setError] = createSignal("")
|
|
28
|
+
let input: InputRenderable | undefined
|
|
29
|
+
let resultScroll: ScrollBoxRenderable | undefined
|
|
30
|
+
let previewScroll: ScrollBoxRenderable | undefined
|
|
31
|
+
|
|
32
|
+
const theme = createMemo(() => props.api.theme.current)
|
|
33
|
+
const syntax = createMemo(() => syntaxStyle(theme()))
|
|
34
|
+
const selectedResult = createMemo(() => results()[selected()])
|
|
35
|
+
const popupWidth = createMemo(() => Math.max(72, Math.min(dimensions().width - 2, Math.floor(dimensions().width * 0.92))))
|
|
36
|
+
const leftWidth = createMemo(() => Math.max(36, Math.min(64, Math.floor(popupWidth() * 0.36))))
|
|
37
|
+
const height = createMemo(() => Math.max(18, dimensions().height - 8))
|
|
38
|
+
const verticalOffset = createMemo(() => Math.floor(dimensions().height / 4 - height() / 2) - 2)
|
|
39
|
+
const dbPath = resolveDatabasePath()
|
|
40
|
+
const directory = props.api.state.path.directory
|
|
41
|
+
|
|
42
|
+
createEffect(() => {
|
|
43
|
+
setTimeout(() => input?.focus(), 1)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
createEffect(() => {
|
|
47
|
+
const q = query().trim()
|
|
48
|
+
setError("")
|
|
49
|
+
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)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
setBusy(true)
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
try {
|
|
62
|
+
setResults(searchSessionMessages(q, { limit: 120, dbPath, directory }))
|
|
63
|
+
setSelected(0)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
setResults([])
|
|
66
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
67
|
+
} finally {
|
|
68
|
+
setBusy(false)
|
|
69
|
+
}
|
|
70
|
+
}, 180)
|
|
71
|
+
onCleanup(() => clearTimeout(timer))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const move = (delta: number) => {
|
|
75
|
+
if (results().length === 0) return
|
|
76
|
+
setSelected((index) => (index + delta + results().length) % results().length)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
createEffect(() => {
|
|
80
|
+
const index = selected()
|
|
81
|
+
const row = resultScroll?.getChildren()[index]
|
|
82
|
+
if (!resultScroll || !row) return
|
|
83
|
+
const y = row.y - resultScroll.y
|
|
84
|
+
if (y < 0) {
|
|
85
|
+
resultScroll.scrollBy(y)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (y + row.height >= resultScroll.height) {
|
|
89
|
+
resultScroll.scrollBy(y + row.height - resultScroll.height + 1)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
createEffect(() => {
|
|
94
|
+
const item = selectedResult()
|
|
95
|
+
if (!item) {
|
|
96
|
+
setPreviewParts([])
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
setPreviewParts(loadConversationWindow(item, { before: 12, after: 24, dbPath }))
|
|
101
|
+
} catch {
|
|
102
|
+
setPreviewParts([])
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
createEffect(() => {
|
|
107
|
+
const item = selectedResult()
|
|
108
|
+
previewParts()
|
|
109
|
+
if (!item) return
|
|
110
|
+
setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const open = () => {
|
|
114
|
+
const item = selectedResult()
|
|
115
|
+
if (!item) return
|
|
116
|
+
const targetID = messageTargetID(item)
|
|
117
|
+
props.api.ui.dialog.clear()
|
|
118
|
+
props.api.route.navigate("session", { sessionID: item.sessionID })
|
|
119
|
+
jumpToRenderedTarget(props.api.renderer.root, targetID)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const scrollPreview = (direction: 1 | -1, evt: ParsedKey) => {
|
|
123
|
+
prevent(evt)
|
|
124
|
+
previewScroll?.scrollBy(direction * previewScrollAmount(previewScroll))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
useKeyboard((evt) => {
|
|
128
|
+
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")) {
|
|
145
|
+
prevent(evt)
|
|
146
|
+
open()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (evt.ctrl && isKey(evt, "d")) {
|
|
150
|
+
scrollPreview(1, evt)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (evt.ctrl && isKey(evt, "u")) {
|
|
154
|
+
scrollPreview(-1, evt)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<box width="100%" alignItems="center">
|
|
161
|
+
<box
|
|
162
|
+
flexDirection="column"
|
|
163
|
+
width={popupWidth()}
|
|
164
|
+
height={height()}
|
|
165
|
+
marginTop={verticalOffset()}
|
|
166
|
+
backgroundColor={theme().backgroundPanel}
|
|
167
|
+
>
|
|
168
|
+
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
|
169
|
+
<box width={1} height="100%" backgroundColor={theme().accent} flexShrink={0} />
|
|
170
|
+
<box flexDirection="column" flexGrow={1} minHeight={0}>
|
|
171
|
+
<box paddingLeft={4} paddingRight={4} paddingTop={1} paddingBottom={1} gap={1} flexShrink={0}>
|
|
172
|
+
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
|
173
|
+
<text fg={theme().text}><span style={{ bold: true }}>Search conversations</span></text>
|
|
174
|
+
<text fg={theme().textMuted} onMouseUp={props.onClose}>esc</text>
|
|
175
|
+
</box>
|
|
176
|
+
<box flexDirection="row" gap={1} flexShrink={0}>
|
|
177
|
+
<input
|
|
178
|
+
ref={(element: InputRenderable) => (input = element)}
|
|
179
|
+
placeholder="grep conversations..."
|
|
180
|
+
placeholderColor={theme().textMuted}
|
|
181
|
+
cursorColor={theme().primary}
|
|
182
|
+
focusedTextColor={theme().text}
|
|
183
|
+
focusedBackgroundColor={theme().backgroundPanel}
|
|
184
|
+
onInput={(value) => setQuery(value)}
|
|
185
|
+
onKeyDown={(evt: ParsedKey) => {
|
|
186
|
+
if (evt.ctrl && isKey(evt, "d")) {
|
|
187
|
+
scrollPreview(1, evt)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if (evt.ctrl && isKey(evt, "u")) scrollPreview(-1, evt)
|
|
191
|
+
}}
|
|
192
|
+
flexGrow={1}
|
|
193
|
+
/>
|
|
194
|
+
<text fg={theme().textMuted}>{busy() ? "searching" : query().trim() ? `${results().length} hits` : `${results().length} recent`}</text>
|
|
195
|
+
</box>
|
|
196
|
+
</box>
|
|
197
|
+
|
|
198
|
+
<box flexDirection="row" flexGrow={1} minHeight={0}>
|
|
199
|
+
<box width={leftWidth()} flexDirection="column" minHeight={0} backgroundColor={theme().backgroundPanel}>
|
|
200
|
+
<scrollbox ref={(element: ScrollBoxRenderable) => (resultScroll = element)} flexGrow={1} minHeight={0} verticalScrollbarOptions={{ visible: false }}>
|
|
201
|
+
<Show
|
|
202
|
+
when={!error()}
|
|
203
|
+
fallback={
|
|
204
|
+
<box flexDirection="column" paddingLeft={1} paddingTop={1}>
|
|
205
|
+
<text fg={theme().error}>database search failed</text>
|
|
206
|
+
<text fg={theme().textMuted}>{error()}</text>
|
|
207
|
+
</box>
|
|
208
|
+
}
|
|
209
|
+
>
|
|
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>
|
|
224
|
+
</Show>
|
|
225
|
+
</Show>
|
|
226
|
+
</scrollbox>
|
|
227
|
+
</box>
|
|
228
|
+
|
|
229
|
+
<box width={1} backgroundColor={theme().backgroundElement} flexShrink={0} />
|
|
230
|
+
|
|
231
|
+
<box flexGrow={1} flexDirection="column" minHeight={0} backgroundColor={theme().background}>
|
|
232
|
+
<PreviewHeader item={selectedResult()} query={query()} theme={theme()} />
|
|
233
|
+
<scrollbox ref={(element: ScrollBoxRenderable) => (previewScroll = element)} flexGrow={1} minHeight={0} paddingLeft={1} paddingRight={1} verticalScrollbarOptions={{ visible: true }}>
|
|
234
|
+
<Show when={selectedResult()} fallback={<text fg={theme().textMuted}>Select a hit to preview the exact matched message.</text>}>
|
|
235
|
+
{(item) => <ConversationPreview item={item()} parts={previewParts()} syntax={syntax()} theme={theme()} />}
|
|
236
|
+
</Show>
|
|
237
|
+
</scrollbox>
|
|
238
|
+
</box>
|
|
239
|
+
</box>
|
|
240
|
+
|
|
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}>
|
|
247
|
+
<text fg={theme().textMuted}>enter open</text>
|
|
248
|
+
<text fg={theme().textMuted}>esc close</text>
|
|
249
|
+
</box>
|
|
250
|
+
</box>
|
|
251
|
+
</box>
|
|
252
|
+
</box>
|
|
253
|
+
</box>
|
|
254
|
+
</box>
|
|
255
|
+
)
|
|
256
|
+
}
|