@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/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 = async (q: string, role: SearchRole | undefined, db: string, dir: string) => {
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
- try {
216
- debug.log("bootstrap:search:start", { limit, directory: dir, role, query: q || "(all recent)" })
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
- setBusy(false)
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
- ? searchSessionMessages(q, { limit, offset, dbPath: db, directory: dir, role })
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
+ })