@bojackduy/opencode-telescope 0.1.24 → 0.1.26
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 +68 -0
- package/search/queries.ts +800 -0
- package/search/schema.ts +120 -0
- package/search/text.ts +348 -0
- package/search/types.ts +131 -0
- package/search/vector.ts +239 -0
- package/search.ts +47 -1510
- package/telescope.tsx +2 -2
- 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
|
@@ -363,7 +363,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
363
363
|
}
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
const loadPreviousResults = (advance = false) => {
|
|
366
|
+
const loadPreviousResults = async (advance = false) => {
|
|
367
367
|
if (advance) advanceSelectionBeforeLoad = true
|
|
368
368
|
const base = resultBaseOffset()
|
|
369
369
|
if (base <= 0) {
|
|
@@ -384,7 +384,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
|
|
|
384
384
|
debug.time("query:load-before")
|
|
385
385
|
try {
|
|
386
386
|
const batch = q
|
|
387
|
-
?
|
|
387
|
+
? await performSearch(q, { limit, offset, dbPath: db, directory: dir, role })
|
|
388
388
|
: recentSessionMessages({ limit, offset, dbPath: db, directory: dir, role })
|
|
389
389
|
const nextSelected = advanceSelectionBeforeLoad && batch.length > 0 ? base - 1 : selected()
|
|
390
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
|
+
})
|