@bojackduy/opencode-telescope 0.1.24 → 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
@@ -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
- ? searchSessionMessages(q, { limit, offset, dbPath: db, directory: dir, role })
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
+ })