@bojackduy/opencode-telescope 0.1.23 → 0.1.25
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/components/preview.tsx +30 -143
- package/components/result-list.tsx +17 -1
- package/package.json +2 -1
- package/search/db-path.ts +67 -0
- package/search/dependencies.test.ts +38 -0
- package/search/dependencies.ts +63 -0
- package/search/embedding.test.ts +83 -0
- package/search/embedding.ts +67 -0
- package/search/queries.ts +813 -0
- package/search/schema.ts +120 -0
- package/search/text.ts +348 -0
- package/search/types.ts +131 -0
- package/search/vector.ts +223 -0
- package/search.ts +47 -1510
- package/telescope.tsx +71 -42
- package/ui/format.test.ts +120 -0
- package/ui/preview-utils.test.ts +161 -0
- package/ui/preview-utils.ts +136 -0
- package/ui/render-target.test.ts +55 -0
package/telescope.tsx
CHANGED
|
@@ -98,6 +98,8 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
98
98
|
let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
|
|
99
99
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
100
100
|
let lastFiredQuery = ""
|
|
101
|
+
let searchRequestId = 0
|
|
102
|
+
let searchWorker: Worker | undefined
|
|
101
103
|
const SEARCH_DEBOUNCE_MS = 200
|
|
102
104
|
type PreviewBeforeIntent = "passive-prefetch" | "explicit-scroll"
|
|
103
105
|
type PendingPreviewBefore = {
|
|
@@ -131,10 +133,60 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
131
133
|
setPrefetchingPreviewAfter(false)
|
|
132
134
|
setLoadingPreviewMore(false)
|
|
133
135
|
}
|
|
136
|
+
const ensureSearchWorker = () => {
|
|
137
|
+
if (searchWorker) return true
|
|
138
|
+
try {
|
|
139
|
+
searchWorker = new Worker(new URL("./search-worker.ts", import.meta.url))
|
|
140
|
+
searchWorker.onmessage = (event: MessageEvent) => {
|
|
141
|
+
const msg = event.data
|
|
142
|
+
if (msg.type === "search-result") {
|
|
143
|
+
if (msg.id !== searchRequestId) return
|
|
144
|
+
const batch = msg.result
|
|
145
|
+
const limit = msg.limit
|
|
146
|
+
debug.log("bootstrap:search:done", { rows: batch.length, limit, mode: searchMode() })
|
|
147
|
+
solidBatch(() => {
|
|
148
|
+
setResults(batch)
|
|
149
|
+
setResultBaseOffset(0)
|
|
150
|
+
setNextResultOffset(batch.length)
|
|
151
|
+
setHasMore(batch.length >= limit)
|
|
152
|
+
setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
|
|
153
|
+
setSelected(0)
|
|
154
|
+
})
|
|
155
|
+
setBusy(false)
|
|
156
|
+
setLoading(false)
|
|
157
|
+
debug.timeEnd("query:search")
|
|
158
|
+
}
|
|
159
|
+
if (msg.type === "error") {
|
|
160
|
+
if (msg.id !== searchRequestId) return
|
|
161
|
+
debug.log("bootstrap:search:error", msg.error)
|
|
162
|
+
solidBatch(() => {
|
|
163
|
+
setResults([])
|
|
164
|
+
setResultBaseOffset(0)
|
|
165
|
+
setNextResultOffset(0)
|
|
166
|
+
setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: 0, lastOffset: 0, lastAdded: 0 })
|
|
167
|
+
})
|
|
168
|
+
setError(msg.error)
|
|
169
|
+
setBusy(false)
|
|
170
|
+
setLoading(false)
|
|
171
|
+
debug.timeEnd("query:search")
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
searchWorker.onerror = (err) => {
|
|
175
|
+
debug.log("worker:error", err.message)
|
|
176
|
+
}
|
|
177
|
+
return true
|
|
178
|
+
} catch (err) {
|
|
179
|
+
debug.log("worker:create-error", err instanceof Error ? err.message : String(err))
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
134
184
|
onCleanup(() => {
|
|
135
185
|
if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
|
|
136
186
|
if (resultPreviousTimer) clearTimeout(resultPreviousTimer)
|
|
137
187
|
cancelPreviewPrefetch()
|
|
188
|
+
searchWorker?.terminate()
|
|
189
|
+
searchWorker = undefined
|
|
138
190
|
})
|
|
139
191
|
|
|
140
192
|
const resultRowHeight = createMemo(() => leftWidth() >= 48 ? 3 : 4)
|
|
@@ -205,41 +257,31 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
205
257
|
return config.disableVector ? "keyword" as const : "hybrid" as const
|
|
206
258
|
}
|
|
207
259
|
|
|
208
|
-
const executeSearch =
|
|
260
|
+
const executeSearch = (q: string, role: SearchRole | undefined, db: string, dir: string) => {
|
|
209
261
|
lastFiredQuery = q
|
|
210
262
|
const limit = q ? Math.min(searchBatchSize(), 80) : recentBatchSize()
|
|
263
|
+
setError("")
|
|
264
|
+
setHasMore(true)
|
|
265
|
+
advanceSelectionAfterLoad = false
|
|
266
|
+
advanceSelectionBeforeLoad = false
|
|
267
|
+
resultNavigationStarted = false
|
|
268
|
+
setLoadingMore(false)
|
|
269
|
+
setLoadingPreviousResults(false)
|
|
270
|
+
setPrefetchingResults(false)
|
|
211
271
|
setLoading(true)
|
|
212
272
|
if (q) setBusy(true)
|
|
213
273
|
setSearchMode(detectSearchMode())
|
|
214
274
|
debug.time("query:search")
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const batch = q
|
|
218
|
-
? await performSearch(q, { limit, offset: 0, dbPath: db, directory: dir, role })
|
|
219
|
-
: recentSessionMessages({ limit, offset: 0, dbPath: db, directory: dir, role })
|
|
220
|
-
debug.log("bootstrap:search:done", { rows: batch.length, limit, mode: searchMode() })
|
|
221
|
-
solidBatch(() => {
|
|
222
|
-
setResults(batch)
|
|
223
|
-
setResultBaseOffset(0)
|
|
224
|
-
setNextResultOffset(batch.length)
|
|
225
|
-
setHasMore(batch.length >= limit)
|
|
226
|
-
setResultPageInfo({ loadedUntil: batch.length, hasMore: batch.length >= limit, pageSize: limit, lastOffset: 0, lastAdded: batch.length })
|
|
227
|
-
setSelected(0)
|
|
228
|
-
})
|
|
229
|
-
} catch (err) {
|
|
230
|
-
debug.log("bootstrap:search:error", err instanceof Error ? err.message : String(err))
|
|
231
|
-
solidBatch(() => {
|
|
232
|
-
setResults([])
|
|
233
|
-
setResultBaseOffset(0)
|
|
234
|
-
setNextResultOffset(0)
|
|
235
|
-
setResultPageInfo({ loadedUntil: 0, hasMore: false, pageSize: limit, lastOffset: 0, lastAdded: 0 })
|
|
236
|
-
})
|
|
237
|
-
setError(err instanceof Error ? err.message : String(err))
|
|
238
|
-
} finally {
|
|
275
|
+
const id = ++searchRequestId
|
|
276
|
+
if (!ensureSearchWorker()) {
|
|
239
277
|
debug.timeEnd("query:search")
|
|
240
|
-
|
|
241
|
-
setLoading(false)
|
|
278
|
+
return
|
|
242
279
|
}
|
|
280
|
+
const msg = q
|
|
281
|
+
? { type: "search" as const, id, query: q, limit, offset: 0, directory: dir, role, dbPath: db }
|
|
282
|
+
: { type: "recent" as const, id, limit, offset: 0, directory: dir, role, dbPath: db }
|
|
283
|
+
debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q || "(all recent)", worker: true })
|
|
284
|
+
searchWorker!.postMessage(msg)
|
|
243
285
|
}
|
|
244
286
|
|
|
245
287
|
const scheduleSearch = (q: string, role: SearchRole | undefined, db: string, dir: string) => {
|
|
@@ -254,21 +296,8 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
254
296
|
createEffect(() => {
|
|
255
297
|
const q = query().trim()
|
|
256
298
|
const role = ownerRole()
|
|
257
|
-
setError("")
|
|
258
|
-
setHasMore(true)
|
|
259
|
-
if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
|
|
260
|
-
if (resultPreviousTimer) clearTimeout(resultPreviousTimer)
|
|
261
|
-
resultPrefetchTimer = undefined
|
|
262
|
-
resultPreviousTimer = undefined
|
|
263
|
-
advanceSelectionAfterLoad = false
|
|
264
|
-
advanceSelectionBeforeLoad = false
|
|
265
|
-
resultNavigationStarted = false
|
|
266
|
-
setLoadingMore(false)
|
|
267
|
-
setLoadingPreviousResults(false)
|
|
268
|
-
setPrefetchingResults(false)
|
|
269
299
|
const db = dbPath()
|
|
270
300
|
const dir = directory
|
|
271
|
-
|
|
272
301
|
scheduleSearch(q, role, db, dir)
|
|
273
302
|
onCleanup(() => {
|
|
274
303
|
if (searchTimer) clearTimeout(searchTimer)
|
|
@@ -334,7 +363,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
334
363
|
}
|
|
335
364
|
}
|
|
336
365
|
|
|
337
|
-
const loadPreviousResults = (advance = false) => {
|
|
366
|
+
const loadPreviousResults = async (advance = false) => {
|
|
338
367
|
if (advance) advanceSelectionBeforeLoad = true
|
|
339
368
|
const base = resultBaseOffset()
|
|
340
369
|
if (base <= 0) {
|
|
@@ -355,7 +384,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
355
384
|
debug.time("query:load-before")
|
|
356
385
|
try {
|
|
357
386
|
const batch = q
|
|
358
|
-
?
|
|
387
|
+
? await performSearch(q, { limit, offset, dbPath: db, directory: dir, role })
|
|
359
388
|
: recentSessionMessages({ limit, offset, dbPath: db, directory: dir, role })
|
|
360
389
|
const nextSelected = advanceSelectionBeforeLoad && batch.length > 0 ? base - 1 : selected()
|
|
361
390
|
debug.log("results:load-before", { offset, limit, added: batch.length, fromBase: base, toBase: offset, cached: results().length + batch.length, advance })
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { compactTime, markdownWithMatch, roleColor, roleLabel, syntaxStyle, toolIcon, toolInputSummary, toolLabel, truncate } from "./format.ts"
|
|
3
|
+
|
|
4
|
+
describe("format utils", () => {
|
|
5
|
+
const mockTheme = {
|
|
6
|
+
info: "#00aaff",
|
|
7
|
+
primary: "#ff6600",
|
|
8
|
+
text: "#cccccc",
|
|
9
|
+
textMuted: "#666666",
|
|
10
|
+
accent: "#ffffff",
|
|
11
|
+
background: "#1a1a1a",
|
|
12
|
+
backgroundPanel: "#222222",
|
|
13
|
+
backgroundElement: "#2a2a2a",
|
|
14
|
+
error: "#ff4444",
|
|
15
|
+
warning: "#ffaa00",
|
|
16
|
+
success: "#44cc44",
|
|
17
|
+
markdownHeading: "#ffaa00",
|
|
18
|
+
markdownStrong: "#ffffff",
|
|
19
|
+
markdownEmph: "#cccccc",
|
|
20
|
+
markdownListItem: "#cccccc",
|
|
21
|
+
markdownBlockQuote: "#888888",
|
|
22
|
+
markdownCode: "#44aaff",
|
|
23
|
+
markdownLink: "#44aaff",
|
|
24
|
+
markdownLinkText: "#ffaa00",
|
|
25
|
+
markdownText: "#cccccc",
|
|
26
|
+
syntaxComment: "#6a9955",
|
|
27
|
+
syntaxString: "#ce9178",
|
|
28
|
+
syntaxNumber: "#b5cea8",
|
|
29
|
+
syntaxKeyword: "#569cd6",
|
|
30
|
+
syntaxType: "#4ec9b0",
|
|
31
|
+
syntaxFunction: "#dcdcaa",
|
|
32
|
+
syntaxOperator: "#d4d4d4",
|
|
33
|
+
syntaxVariable: "#9cdcfe",
|
|
34
|
+
syntaxPunctuation: "#d4d4d4",
|
|
35
|
+
diffAdded: "#44cc44",
|
|
36
|
+
diffRemoved: "#ff4444",
|
|
37
|
+
diffContext: "#888888",
|
|
38
|
+
diffAddedBg: "#003300",
|
|
39
|
+
diffRemovedBg: "#330000",
|
|
40
|
+
diffContextBg: "#222222",
|
|
41
|
+
diffHighlightAdded: "#66ff66",
|
|
42
|
+
diffHighlightRemoved: "#ff6666",
|
|
43
|
+
diffLineNumber: "#666666",
|
|
44
|
+
diffAddedLineNumberBg: "#003300",
|
|
45
|
+
diffRemovedLineNumberBg: "#330000",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("compactTime formats timestamp", () => {
|
|
49
|
+
const t = new Date("2024-03-15T14:30:00").getTime()
|
|
50
|
+
const result = compactTime(t)
|
|
51
|
+
expect(result).toContain("Mar")
|
|
52
|
+
expect(result).toContain("15")
|
|
53
|
+
expect(result).toMatch(/(14:30|02:30)/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("roleLabel returns correct labels", () => {
|
|
57
|
+
expect(roleLabel("user")).toBe("you")
|
|
58
|
+
expect(roleLabel("assistant")).toBe("assistant")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("roleColor returns correct colors", () => {
|
|
62
|
+
expect(roleColor("assistant", mockTheme as never)).toBe(mockTheme.info as never)
|
|
63
|
+
expect(roleColor("user", mockTheme as never)).toBe(mockTheme.primary as never)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("toolIcon returns correct symbols", () => {
|
|
67
|
+
expect(toolIcon("bash")).toBe("$")
|
|
68
|
+
expect(toolIcon("read")).toBe("R")
|
|
69
|
+
expect(toolIcon("grep")).toBe("G")
|
|
70
|
+
expect(toolIcon("glob")).toBe("*")
|
|
71
|
+
expect(toolIcon("write")).toBe("W")
|
|
72
|
+
expect(toolIcon("edit")).toBe("W")
|
|
73
|
+
expect(toolIcon("apply_patch")).toBe("W")
|
|
74
|
+
expect(toolIcon("task")).toBe("T")
|
|
75
|
+
expect(toolIcon("todowrite")).toBe("☑")
|
|
76
|
+
expect(toolIcon("webfetch")).toBe("@")
|
|
77
|
+
expect(toolIcon("websearch")).toBe("@")
|
|
78
|
+
expect(toolIcon("skill")).toBe("S")
|
|
79
|
+
expect(toolIcon("question")).toBe("?")
|
|
80
|
+
expect(toolIcon("unknown")).toBe("⚙")
|
|
81
|
+
expect(toolIcon(undefined)).toBe("⚙")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("toolLabel returns tool name or default", () => {
|
|
85
|
+
expect(toolLabel("bash")).toBe("bash")
|
|
86
|
+
expect(toolLabel(undefined)).toBe("tool")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("toolInputSummary extracts common fields", () => {
|
|
90
|
+
expect(toolInputSummary({ command: "npm test" })).toBe("npm test")
|
|
91
|
+
expect(toolInputSummary({ filePath: "src/index.ts" })).toBe("src/index.ts")
|
|
92
|
+
expect(toolInputSummary({ pattern: "*.ts" })).toBe("*.ts")
|
|
93
|
+
expect(toolInputSummary({})).toBe("{}")
|
|
94
|
+
expect(toolInputSummary(null)).toBe("")
|
|
95
|
+
expect(toolInputSummary("string")).toBe("")
|
|
96
|
+
expect(toolInputSummary(42)).toBe("")
|
|
97
|
+
expect(toolInputSummary({ unknown: "x".repeat(200) })).toHaveLength(90)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("markdownWithMatch highlights when match and highlight flag set", () => {
|
|
101
|
+
expect(markdownWithMatch("before ", "match", " after", true))
|
|
102
|
+
.toBe("before **match** after")
|
|
103
|
+
expect(markdownWithMatch("before ", "match", " after", false))
|
|
104
|
+
.toBe("before match after")
|
|
105
|
+
expect(markdownWithMatch("text", "", "", true))
|
|
106
|
+
.toBe("text")
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test("syntaxStyle returns a SyntaxStyle instance", () => {
|
|
110
|
+
const style = syntaxStyle(mockTheme as never)
|
|
111
|
+
expect(style).toBeDefined()
|
|
112
|
+
expect(typeof style).toBe("object")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test("truncate handles short and long strings", () => {
|
|
116
|
+
expect(truncate("hello", 10)).toBe("hello")
|
|
117
|
+
expect(truncate("hello world", 8)).toBe("hello w…")
|
|
118
|
+
expect(truncate("", 5)).toBe("")
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { clippedText, containsOrderedTokens, conversationMatch, filetype, matchExcerpt, parseApplyPatchFiles, shortPath } from "./preview-utils.ts"
|
|
3
|
+
|
|
4
|
+
describe("containsOrderedTokens", () => {
|
|
5
|
+
test("matches tokens in order", () => {
|
|
6
|
+
expect(containsOrderedTokens("let me test this", "let me")).toBe(true)
|
|
7
|
+
expect(containsOrderedTokens("let me test this", "test this")).toBe(true)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test("matches with gaps between tokens", () => {
|
|
11
|
+
expect(containsOrderedTokens("let us now test me please", "let me")).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("rejects out-of-order tokens", () => {
|
|
15
|
+
expect(containsOrderedTokens("me let", "let me")).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("returns false for empty query", () => {
|
|
19
|
+
expect(containsOrderedTokens("hello", "")).toBe(false)
|
|
20
|
+
expect(containsOrderedTokens("hello", " ")).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("is case insensitive", () => {
|
|
24
|
+
expect(containsOrderedTokens("HELLO WORLD", "hello world")).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("clippedText", () => {
|
|
29
|
+
test("returns full text when under threshold", () => {
|
|
30
|
+
const result = clippedText("short text", "text", 10)
|
|
31
|
+
expect(result.clipped).toBe(false)
|
|
32
|
+
expect(result.text).toBe("short text")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("clips large text without match line", () => {
|
|
36
|
+
const large = "line\n".repeat(500)
|
|
37
|
+
const result = clippedText(large, "nonexistent", 10)
|
|
38
|
+
expect(result.clipped).toBe(true)
|
|
39
|
+
expect(result.text.split("\n").length).toBeLessThan(30)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("clips large text around match line", () => {
|
|
43
|
+
const lines: string[] = []
|
|
44
|
+
for (let i = 0; i < 500; i++) lines.push(`line ${i}`)
|
|
45
|
+
lines[250] = "needle here"
|
|
46
|
+
const result = clippedText(lines.join("\n"), "needle", 10)
|
|
47
|
+
expect(result.clipped).toBe(true)
|
|
48
|
+
expect(result.text).toContain("needle")
|
|
49
|
+
expect(result.text).toContain("lines omitted")
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe("matchExcerpt", () => {
|
|
54
|
+
test("returns snippet around match", () => {
|
|
55
|
+
const text = "a ".repeat(50) + "needle here" + " b".repeat(50)
|
|
56
|
+
const result = matchExcerpt(text, "needle")
|
|
57
|
+
expect(result).toBeDefined()
|
|
58
|
+
expect(result!.match).toContain("needle")
|
|
59
|
+
expect(result!.before).toContain("...")
|
|
60
|
+
expect(result!.after).toContain("...")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("returns undefined for empty query", () => {
|
|
64
|
+
expect(matchExcerpt("hello", "")).toBeUndefined()
|
|
65
|
+
expect(matchExcerpt("hello", " ")).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("returns undefined when query not found", () => {
|
|
69
|
+
expect(matchExcerpt("hello world", "missing")).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("conversationMatch", () => {
|
|
74
|
+
test("finds match in text", () => {
|
|
75
|
+
const result = conversationMatch("hello world", true, "world")
|
|
76
|
+
expect(result).toEqual({ start: 6, end: 11 })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("returns undefined when target is false", () => {
|
|
80
|
+
expect(conversationMatch("hello", false, "hello")).toBeUndefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("returns undefined when match is empty", () => {
|
|
84
|
+
expect(conversationMatch("hello", true, "")).toBeUndefined()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test("returns undefined when no match found", () => {
|
|
88
|
+
expect(conversationMatch("hello", true, "world")).toBeUndefined()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("parseApplyPatchFiles", () => {
|
|
93
|
+
test("parses valid metadata", () => {
|
|
94
|
+
const metadata = {
|
|
95
|
+
files: [
|
|
96
|
+
{ filePath: "/project/src/index.ts", patch: "diff content", type: "update", deletions: 0 },
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
const result = parseApplyPatchFiles(metadata)
|
|
100
|
+
expect(result).toHaveLength(1)
|
|
101
|
+
expect(result[0]!.filePath).toBe("/project/src/index.ts")
|
|
102
|
+
expect(result[0]!.relativePath).toBe("/project/src/index.ts")
|
|
103
|
+
expect(result[0]!.patch).toBe("diff content")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("returns empty for missing or invalid metadata", () => {
|
|
107
|
+
expect(parseApplyPatchFiles(null)).toEqual([])
|
|
108
|
+
expect(parseApplyPatchFiles({})).toEqual([])
|
|
109
|
+
expect(parseApplyPatchFiles({ files: "not-array" })).toEqual([])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("skips entries with missing required fields", () => {
|
|
113
|
+
const metadata = {
|
|
114
|
+
files: [
|
|
115
|
+
{ filePath: "/a.ts", patch: "diff" },
|
|
116
|
+
{ filePath: "/b.ts" },
|
|
117
|
+
{ patch: "diff" },
|
|
118
|
+
],
|
|
119
|
+
}
|
|
120
|
+
const result = parseApplyPatchFiles(metadata)
|
|
121
|
+
expect(result).toHaveLength(1)
|
|
122
|
+
expect(result[0]!.filePath).toBe("/a.ts")
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe("shortPath", () => {
|
|
127
|
+
test("returns last 3 segments", () => {
|
|
128
|
+
expect(shortPath("/project/src/components/Button.tsx")).toBe("src/components/Button.tsx")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("handles short paths", () => {
|
|
132
|
+
expect(shortPath("file.ts")).toBe("file.ts")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("returns default for empty path", () => {
|
|
136
|
+
expect(shortPath("")).toBe("file")
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe("filetype", () => {
|
|
141
|
+
test("maps known extensions", () => {
|
|
142
|
+
expect(filetype("file.ts")).toBe("typescript")
|
|
143
|
+
expect(filetype("file.tsx")).toBe("typescript")
|
|
144
|
+
expect(filetype("file.py")).toBe("python")
|
|
145
|
+
expect(filetype("file.go")).toBe("go")
|
|
146
|
+
expect(filetype("file.rs")).toBe("rust")
|
|
147
|
+
expect(filetype("file.md")).toBe("markdown")
|
|
148
|
+
expect(filetype("file.json")).toBe("json")
|
|
149
|
+
expect(filetype("file.sh")).toBe("shellscript")
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test("returns 'none' for empty or unknown", () => {
|
|
153
|
+
expect(filetype("file")).toBe("none")
|
|
154
|
+
expect(filetype("file.unknown")).toBe("unknown")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test("is case insensitive", () => {
|
|
158
|
+
expect(filetype("file.TS")).toBe("typescript")
|
|
159
|
+
expect(filetype("file.MD")).toBe("markdown")
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export function containsOrderedTokens(text: string, query: string) {
|
|
2
|
+
const tokens = query.trim().split(/\s+/).filter(Boolean)
|
|
3
|
+
if (tokens.length === 0) return false
|
|
4
|
+
const lower = text.toLowerCase()
|
|
5
|
+
let searchPos = 0
|
|
6
|
+
for (const token of tokens) {
|
|
7
|
+
const index = lower.indexOf(token.toLowerCase(), searchPos)
|
|
8
|
+
if (index === -1) return false
|
|
9
|
+
searchPos = index + token.length
|
|
10
|
+
}
|
|
11
|
+
return true
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function findOrderedTokenLine(lines: string[], query: string) {
|
|
15
|
+
const tokens = query.trim().split(/\s+/).filter(Boolean)
|
|
16
|
+
if (tokens.length === 0) return -1
|
|
17
|
+
for (let index = 0; index < lines.length; index++) {
|
|
18
|
+
if (containsOrderedTokens(lines[index]!, query)) return index
|
|
19
|
+
}
|
|
20
|
+
return -1
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function clippedText(text: string, query: string, radiusLines: number) {
|
|
24
|
+
const lines = text.split("\n")
|
|
25
|
+
const tooLarge = text.length > 30000 || lines.length > 420
|
|
26
|
+
if (!tooLarge) return { text, clipped: false }
|
|
27
|
+
|
|
28
|
+
const matchLine = findOrderedTokenLine(lines, query)
|
|
29
|
+
if (matchLine === -1) {
|
|
30
|
+
return { text: lines.slice(0, radiusLines * 2).join("\n"), clipped: true }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const start = Math.max(0, matchLine - radiusLines)
|
|
34
|
+
const end = Math.min(lines.length, matchLine + radiusLines + 1)
|
|
35
|
+
return {
|
|
36
|
+
text: [
|
|
37
|
+
start > 0 ? `... ${start} lines omitted ...` : undefined,
|
|
38
|
+
...lines.slice(start, end),
|
|
39
|
+
end < lines.length ? `... ${lines.length - end} lines omitted ...` : undefined,
|
|
40
|
+
].filter(Boolean).join("\n"),
|
|
41
|
+
clipped: true,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function matchExcerpt(text: string, query: string, radius = 80) {
|
|
46
|
+
const needle = query.trim()
|
|
47
|
+
if (!needle) return
|
|
48
|
+
const tokens = needle.split(/\s+/)
|
|
49
|
+
const lowerText = text.toLowerCase()
|
|
50
|
+
let searchPos = 0
|
|
51
|
+
let firstStart = -1
|
|
52
|
+
let lastEnd = -1
|
|
53
|
+
for (const token of tokens) {
|
|
54
|
+
const start = lowerText.indexOf(token.toLowerCase(), searchPos)
|
|
55
|
+
if (start === -1) return
|
|
56
|
+
if (firstStart === -1) firstStart = start
|
|
57
|
+
searchPos = start + token.length
|
|
58
|
+
lastEnd = searchPos
|
|
59
|
+
}
|
|
60
|
+
const beforeStart = Math.max(0, firstStart - radius)
|
|
61
|
+
const afterEnd = Math.min(text.length, lastEnd + radius)
|
|
62
|
+
return {
|
|
63
|
+
before: `${beforeStart > 0 ? "..." : ""}${text.slice(beforeStart, firstStart).replace(/\s+/g, " ")}`,
|
|
64
|
+
match: text.slice(firstStart, lastEnd),
|
|
65
|
+
after: `${text.slice(lastEnd, afterEnd).replace(/\s+/g, " ")}${afterEnd < text.length ? "..." : ""}`,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function conversationMatch(text: string, target: boolean, match: string) {
|
|
70
|
+
if (!target || !match) return
|
|
71
|
+
const tokens = match.split(/\s+/)
|
|
72
|
+
const lowerText = text.toLowerCase()
|
|
73
|
+
let searchPos = 0
|
|
74
|
+
let firstStart = -1
|
|
75
|
+
let lastEnd = -1
|
|
76
|
+
for (const token of tokens) {
|
|
77
|
+
const index = lowerText.indexOf(token.toLowerCase(), searchPos)
|
|
78
|
+
if (index === -1) return
|
|
79
|
+
if (firstStart === -1) firstStart = index
|
|
80
|
+
searchPos = index + token.length
|
|
81
|
+
lastEnd = searchPos
|
|
82
|
+
}
|
|
83
|
+
return { start: firstStart, end: lastEnd }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseApplyPatchFiles(metadata: unknown) {
|
|
87
|
+
const files = recordValue(metadata)?.files
|
|
88
|
+
if (!Array.isArray(files)) return []
|
|
89
|
+
return files.flatMap((item) => {
|
|
90
|
+
const file = recordValue(item)
|
|
91
|
+
const filePath = stringValue(file?.filePath)
|
|
92
|
+
const relativePath = stringValue(file?.relativePath) ?? filePath
|
|
93
|
+
const patch = stringValue(file?.patch)
|
|
94
|
+
const type = stringValue(file?.type) ?? "update"
|
|
95
|
+
const deletions = numberValue(file?.deletions) ?? 0
|
|
96
|
+
if (!filePath || !relativePath || patch === undefined) return []
|
|
97
|
+
return [{ filePath, relativePath, patch, type, deletions }]
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function shortPath(value: string) {
|
|
102
|
+
if (!value) return "file"
|
|
103
|
+
const parts = value.split(/[\\/]/)
|
|
104
|
+
return parts.slice(-3).join("/")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function filetype(input: string) {
|
|
108
|
+
const ext = input.split(".").at(-1)?.toLowerCase()
|
|
109
|
+
if (!ext || ext === input.toLowerCase()) return "none"
|
|
110
|
+
if (["ts", "tsx", "js", "jsx", "mts", "cts"].includes(ext)) return "typescript"
|
|
111
|
+
if (ext === "py") return "python"
|
|
112
|
+
if (ext === "go") return "go"
|
|
113
|
+
if (ext === "rs") return "rust"
|
|
114
|
+
if (ext === "rb") return "ruby"
|
|
115
|
+
if (ext === "java") return "java"
|
|
116
|
+
if (ext === "json") return "json"
|
|
117
|
+
if (ext === "md") return "markdown"
|
|
118
|
+
if (ext === "yml" || ext === "yaml") return "yaml"
|
|
119
|
+
if (ext === "sql") return "sql"
|
|
120
|
+
if (ext === "sh" || ext === "bash" || ext === "zsh") return "shellscript"
|
|
121
|
+
if (ext === "diff" || ext === "patch") return "diff"
|
|
122
|
+
return ext
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function recordValue(value: unknown): Record<string, unknown> | undefined {
|
|
126
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
|
127
|
+
return value as Record<string, unknown>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function stringValue(value: unknown) {
|
|
131
|
+
return typeof value === "string" ? value : undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function numberValue(value: unknown) {
|
|
135
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined
|
|
136
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { findRenderableByID, messageTargetID, previewScrollAmount } from "./render-target.ts"
|
|
3
|
+
import type { SearchResult } from "../search.ts"
|
|
4
|
+
|
|
5
|
+
describe("render-target utils", () => {
|
|
6
|
+
test("messageTargetID returns correct IDs", () => {
|
|
7
|
+
const toolItem = { partType: "tool", messageID: "msg_1", id: "prt_1" } as SearchResult
|
|
8
|
+
expect(messageTargetID(toolItem)).toBe("tool-msg_1-prt_1")
|
|
9
|
+
|
|
10
|
+
const reasoningItem = { partType: "reasoning", messageID: "msg_2", id: "prt_2" } as SearchResult
|
|
11
|
+
expect(messageTargetID(reasoningItem)).toBe("text-msg_2-prt_2")
|
|
12
|
+
|
|
13
|
+
const assistantItem = { partType: "text", role: "assistant", messageID: "msg_3", id: "prt_3" } as SearchResult
|
|
14
|
+
expect(messageTargetID(assistantItem)).toBe("text-msg_3-prt_3")
|
|
15
|
+
|
|
16
|
+
const userItem = { partType: "text", role: "user", messageID: "msg_4", id: "prt_4" } as SearchResult
|
|
17
|
+
expect(messageTargetID(userItem)).toBe("msg_4")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("previewScrollAmount returns minimum of 1", () => {
|
|
21
|
+
expect(previewScrollAmount(undefined)).toBe(1)
|
|
22
|
+
expect(previewScrollAmount({ height: 0 } as never)).toBe(1)
|
|
23
|
+
expect(previewScrollAmount({ height: 10 } as never)).toBe(1)
|
|
24
|
+
expect(previewScrollAmount({ height: 24 } as never)).toBe(3)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("findRenderableByID traverses render tree", () => {
|
|
28
|
+
const tree = {
|
|
29
|
+
id: "root",
|
|
30
|
+
y: 0,
|
|
31
|
+
getChildren() {
|
|
32
|
+
return [
|
|
33
|
+
{ id: "child1", y: 1, height: 10, getChildren: () => [] },
|
|
34
|
+
{
|
|
35
|
+
id: "child2",
|
|
36
|
+
y: 11,
|
|
37
|
+
height: 20,
|
|
38
|
+
getChildren: () => [
|
|
39
|
+
{ id: "target", y: 12, height: 5, getChildren: () => [] },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const found = findRenderableByID(tree, "target")
|
|
47
|
+
expect(found).toBeDefined()
|
|
48
|
+
expect(found!.id).toBe("target")
|
|
49
|
+
expect(found!.y).toBe(12)
|
|
50
|
+
|
|
51
|
+
expect(findRenderableByID(tree, "nonexistent")).toBeUndefined()
|
|
52
|
+
expect(findRenderableByID(null, "x")).toBeUndefined()
|
|
53
|
+
expect(findRenderableByID("string", "x")).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
})
|