@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 +29 -5
- package/components/preview.tsx +31 -12
- package/components/result-list.tsx +22 -0
- package/package.json +12 -3
- package/search.ts +131 -74
- package/telescope.tsx +213 -69
- package/tsconfig.json +2 -1
- package/ui/debug.ts +25 -0
package/README.md
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
# opencode-telescope
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
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.
|
package/components/preview.tsx
CHANGED
|
@@ -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
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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,
|
|
253
|
-
match: text.slice(
|
|
254
|
-
after: `${text.slice(
|
|
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.
|
|
5
|
-
"description": "
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
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 +
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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(...
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
props.onClose()
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
if (isKey(evt, "down") || (evt.ctrl && isKey(evt, "j"))) {
|
|
135
|
-
prevent(evt)
|
|
136
|
-
move(1)
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
if (isKey(evt, "up") || (evt.ctrl && isKey(evt, "k"))) {
|
|
140
|
-
prevent(evt)
|
|
141
|
-
move(-1)
|
|
142
|
-
return
|
|
143
|
-
}
|
|
144
|
-
if (isKey(evt, "enter", "return")) {
|
|
224
|
+
|
|
225
|
+
if (isKey(evt, "down") || isKey(evt, "up")) {
|
|
145
226
|
prevent(evt)
|
|
146
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
187
|
-
|
|
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, "
|
|
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={
|
|
211
|
-
<
|
|
212
|
-
{(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
<
|
|
242
|
-
<box flexDirection="row" gap={2}>
|
|
243
|
-
<text fg={theme().
|
|
244
|
-
<text fg={theme().textMuted}
|
|
245
|
-
|
|
246
|
-
|
|
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}
|
|
383
|
+
<text fg={theme().textMuted}>·</text>
|
|
384
|
+
<text fg={theme().text}>q close</text>
|
|
249
385
|
</box>
|
|
250
|
-
</
|
|
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
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
|
+
}
|