@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/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
+ }