@bojackduy/opencode-telescope 0.1.2
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/LICENSE +21 -0
- package/README.md +25 -0
- package/components/preview.tsx +256 -0
- package/components/result-list.tsx +82 -0
- package/package.json +63 -0
- package/search.ts +448 -0
- package/telescope.tsx +256 -0
- package/tsconfig.json +17 -0
- package/tui.tsx +46 -0
- package/ui/format.ts +106 -0
- package/ui/keyboard.ts +14 -0
- package/ui/render-target.ts +76 -0
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "preserve",
|
|
7
|
+
"jsxImportSource": "@opentui/solid",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"types": ["bun"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["**/*.ts", "**/*.tsx"]
|
|
17
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import { Telescope } from "./telescope.tsx"
|
|
4
|
+
|
|
5
|
+
const id = "opencode-telescope"
|
|
6
|
+
|
|
7
|
+
const enabled = (options: unknown) => {
|
|
8
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) return true
|
|
9
|
+
const value = (options as Record<string, unknown>).enabled
|
|
10
|
+
return typeof value === "boolean" ? value : true
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const tui: TuiPlugin = async (api: TuiPluginApi, options: unknown) => {
|
|
14
|
+
if (!enabled(options)) return
|
|
15
|
+
|
|
16
|
+
const command = "opencode.telescope.sessions"
|
|
17
|
+
const open = () => {
|
|
18
|
+
api.ui.dialog.replace(() => <Telescope api={api} onClose={() => api.ui.dialog.clear()} />)
|
|
19
|
+
api.ui.dialog.setSize("xlarge")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const unregisterKeymap = api.keymap.registerLayer({
|
|
23
|
+
commands: [
|
|
24
|
+
{
|
|
25
|
+
name: command,
|
|
26
|
+
title: "Telescope Sessions",
|
|
27
|
+
category: "Search",
|
|
28
|
+
namespace: "palette",
|
|
29
|
+
slashName: "telescope",
|
|
30
|
+
run: open,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
bindings: [{ key: "<leader>f", desc: "Search conversations", group: "Search", cmd: open }],
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
api.lifecycle.onDispose(() => {
|
|
37
|
+
unregisterKeymap()
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
42
|
+
id,
|
|
43
|
+
tui,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default plugin
|
package/ui/format.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { SyntaxStyle } from "@opentui/core"
|
|
2
|
+
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import type { SearchResult } from "../search.ts"
|
|
4
|
+
|
|
5
|
+
export function compactTime(time: number) {
|
|
6
|
+
const date = new Date(time)
|
|
7
|
+
return `${date.toLocaleDateString([], { month: "short", day: "numeric" })} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function roleLabel(role: SearchResult["role"]) {
|
|
11
|
+
return role === "assistant" ? "assistant" : "you"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function roleColor(role: SearchResult["role"], theme: TuiThemeCurrent) {
|
|
15
|
+
return role === "assistant" ? theme.info : theme.primary
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function reasoningSummary(text: string) {
|
|
19
|
+
const lines = text.split("\n").map((line) => line.trim()).filter(Boolean)
|
|
20
|
+
const title = lines[0] ? truncate(lines[0].replace(/^#+\s*/, ""), 90) : null
|
|
21
|
+
return {
|
|
22
|
+
title,
|
|
23
|
+
body: lines.slice(1).join("\n").trim(),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toolIcon(tool: string | undefined) {
|
|
28
|
+
if (tool === "bash") return "$"
|
|
29
|
+
if (tool === "read") return "R"
|
|
30
|
+
if (tool === "grep") return "G"
|
|
31
|
+
if (tool === "glob") return "*"
|
|
32
|
+
if (tool === "write" || tool === "edit" || tool === "apply_patch") return "W"
|
|
33
|
+
if (tool === "task") return "T"
|
|
34
|
+
if (tool === "todowrite") return "☑"
|
|
35
|
+
if (tool === "webfetch" || tool === "websearch") return "@"
|
|
36
|
+
if (tool === "skill") return "S"
|
|
37
|
+
if (tool === "question") return "?"
|
|
38
|
+
return "⚙"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function toolLabel(tool: string | undefined) {
|
|
42
|
+
return tool ?? "tool"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function toolInputSummary(input: unknown) {
|
|
46
|
+
if (!input || typeof input !== "object") return ""
|
|
47
|
+
const record = input as Record<string, unknown>
|
|
48
|
+
const value = record.command ?? record.filePath ?? record.pattern ?? record.url ?? record.description ?? record.name
|
|
49
|
+
if (typeof value === "string") return truncate(value.replace(/\s+/g, " "), 90)
|
|
50
|
+
return truncate(JSON.stringify(record), 90)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function markdownWithMatch(before: string, match: string, after: string, highlight: boolean) {
|
|
54
|
+
if (!match || !highlight) return `${before}${match}${after}`
|
|
55
|
+
return `${before}**${escapeMarkdownInline(match)}**${after}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function syntaxStyle(theme: TuiThemeCurrent) {
|
|
59
|
+
return SyntaxStyle.fromTheme([
|
|
60
|
+
{ scope: ["default"], style: { foreground: theme.text } },
|
|
61
|
+
{ scope: ["comment", "comment.documentation"], style: { foreground: theme.syntaxComment, italic: true } },
|
|
62
|
+
{ scope: ["string", "symbol", "character", "character.special"], style: { foreground: theme.syntaxString } },
|
|
63
|
+
{ scope: ["number", "boolean", "float", "constant"], style: { foreground: theme.syntaxNumber } },
|
|
64
|
+
{ scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine", "keyword", "keyword.directive", "keyword.modifier", "keyword.exception"], style: { foreground: theme.syntaxKeyword, italic: true } },
|
|
65
|
+
{ scope: ["keyword.type"], style: { foreground: theme.syntaxType, bold: true, italic: true } },
|
|
66
|
+
{ scope: ["keyword.import", "keyword.export", "tag.attribute"], style: { foreground: theme.syntaxKeyword } },
|
|
67
|
+
{ scope: ["keyword.function", "function.method", "variable.member", "function", "constructor"], style: { foreground: theme.syntaxFunction } },
|
|
68
|
+
{ scope: ["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary", "punctuation.special", "tag.delimiter"], style: { foreground: theme.syntaxOperator } },
|
|
69
|
+
{ scope: ["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"], style: { foreground: theme.syntaxVariable } },
|
|
70
|
+
{ scope: ["type", "module", "class", "namespace"], style: { foreground: theme.syntaxType } },
|
|
71
|
+
{ scope: ["punctuation", "punctuation.bracket"], style: { foreground: theme.syntaxPunctuation } },
|
|
72
|
+
{ scope: ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super", "tag"], style: { foreground: theme.error } },
|
|
73
|
+
{ scope: ["string.escape", "string.regexp"], style: { foreground: theme.syntaxKeyword } },
|
|
74
|
+
{ scope: ["markup.heading"], style: { foreground: theme.markdownHeading, bold: true } },
|
|
75
|
+
{ scope: ["markup.heading.1"], style: { foreground: theme.markdownHeading, bold: true, underline: true } },
|
|
76
|
+
{ scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } },
|
|
77
|
+
{ scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } },
|
|
78
|
+
{ scope: ["markup.list"], style: { foreground: theme.markdownListItem } },
|
|
79
|
+
{ scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
|
|
80
|
+
{ scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode } },
|
|
81
|
+
{ scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, background: theme.background } },
|
|
82
|
+
{ scope: ["markup.link", "markup.link.url", "string.special", "string.special.url"], style: { foreground: theme.markdownLink, underline: true } },
|
|
83
|
+
{ scope: ["markup.link.label", "label"], style: { foreground: theme.markdownLinkText, underline: true } },
|
|
84
|
+
{ scope: ["spell", "nospell", "markup.underline"], style: { foreground: theme.text } },
|
|
85
|
+
{ scope: ["conceal", "markup.strikethrough", "markup.list.unchecked", "debug"], style: { foreground: theme.textMuted } },
|
|
86
|
+
{ scope: ["comment.error", "error"], style: { foreground: theme.error, italic: true, bold: true } },
|
|
87
|
+
{ scope: ["comment.warning", "warning"], style: { foreground: theme.warning, italic: true, bold: true } },
|
|
88
|
+
{ scope: ["comment.todo", "comment.note"], style: { foreground: theme.info, italic: true, bold: true } },
|
|
89
|
+
{ scope: ["type.definition"], style: { foreground: theme.syntaxType, bold: true } },
|
|
90
|
+
{ scope: ["attribute", "annotation"], style: { foreground: theme.warning } },
|
|
91
|
+
{ scope: ["markup.list.checked"], style: { foreground: theme.success } },
|
|
92
|
+
{ scope: ["diff.plus"], style: { foreground: theme.diffAdded, background: theme.diffAddedBg } },
|
|
93
|
+
{ scope: ["diff.minus"], style: { foreground: theme.diffRemoved, background: theme.diffRemovedBg } },
|
|
94
|
+
{ scope: ["diff.delta"], style: { foreground: theme.diffContext, background: theme.diffContextBg } },
|
|
95
|
+
{ scope: ["info"], style: { foreground: theme.info } },
|
|
96
|
+
])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function truncate(value: string, length: number) {
|
|
100
|
+
if (value.length <= length) return value
|
|
101
|
+
return `${value.slice(0, length - 1)}…`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function escapeMarkdownInline(value: string) {
|
|
105
|
+
return value.replace(/[\\*_`[\]]/g, "\\$&")
|
|
106
|
+
}
|
package/ui/keyboard.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ParsedKey } from "@opentui/core"
|
|
2
|
+
|
|
3
|
+
export function isKey(evt: ParsedKey, ...names: string[]) {
|
|
4
|
+
return names.includes(evt.name)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function prevent(evt: ParsedKey) {
|
|
8
|
+
const controlled = evt as ParsedKey & {
|
|
9
|
+
preventDefault?: () => void
|
|
10
|
+
stopPropagation?: () => void
|
|
11
|
+
}
|
|
12
|
+
controlled.preventDefault?.()
|
|
13
|
+
controlled.stopPropagation?.()
|
|
14
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import type { SearchResult } from "../search.ts"
|
|
3
|
+
|
|
4
|
+
export function previewScrollAmount(scroll: ScrollBoxRenderable | undefined) {
|
|
5
|
+
return Math.max(1, Math.floor((scroll?.height || 10) / 8))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function messageTargetID(item: SearchResult) {
|
|
9
|
+
if (item.role === "assistant") return `text-${item.messageID}-${item.id}`
|
|
10
|
+
return item.messageID
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function scrollPreviewToTarget(scroll: ScrollBoxRenderable | undefined, targetID: string) {
|
|
14
|
+
if (!scroll) return
|
|
15
|
+
const target = findRenderableByID(scroll, targetID)
|
|
16
|
+
if (!target) return
|
|
17
|
+
scroll.scrollBy(target.y - scroll.y - Math.max(1, Math.floor(scroll.height / 3)))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function jumpToRenderedTarget(root: unknown, targetID: string) {
|
|
21
|
+
let attempts = 0
|
|
22
|
+
const tick = () => {
|
|
23
|
+
const hit = findRenderableTarget(root, targetID)
|
|
24
|
+
if (hit) {
|
|
25
|
+
hit.scroll.scrollBy(hit.target.y - hit.scroll.y - 1)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
attempts++
|
|
29
|
+
if (attempts < 40) setTimeout(tick, 50)
|
|
30
|
+
}
|
|
31
|
+
setTimeout(tick, 50)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type RenderNode = {
|
|
35
|
+
id?: string
|
|
36
|
+
y: number
|
|
37
|
+
getChildren(): unknown[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type ScrollNode = RenderNode & {
|
|
41
|
+
scrollBy(delta: number): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findRenderableTarget(node: unknown, targetID: string, scroll?: ScrollNode): { target: RenderNode; scroll: ScrollNode } | undefined {
|
|
45
|
+
if (!isRenderNode(node)) return
|
|
46
|
+
const nextScroll = isScrollNode(node) ? node : scroll
|
|
47
|
+
if (node.id === targetID && nextScroll) return { target: node, scroll: nextScroll }
|
|
48
|
+
for (const child of node.getChildren()) {
|
|
49
|
+
const result = findRenderableTarget(child, targetID, nextScroll)
|
|
50
|
+
if (result) return result
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isRenderNode(value: unknown): value is RenderNode {
|
|
55
|
+
return Boolean(
|
|
56
|
+
value &&
|
|
57
|
+
typeof value === "object" &&
|
|
58
|
+
"y" in value &&
|
|
59
|
+
typeof value.y === "number" &&
|
|
60
|
+
"getChildren" in value &&
|
|
61
|
+
typeof value.getChildren === "function",
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isScrollNode(value: RenderNode): value is ScrollNode {
|
|
66
|
+
return "scrollBy" in value && typeof value.scrollBy === "function"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findRenderableByID(node: unknown, targetID: string): RenderNode | undefined {
|
|
70
|
+
if (!isRenderNode(node)) return
|
|
71
|
+
if (node.id === targetID) return node
|
|
72
|
+
for (const child of node.getChildren()) {
|
|
73
|
+
const result = findRenderableByID(child, targetID)
|
|
74
|
+
if (result) return result
|
|
75
|
+
}
|
|
76
|
+
}
|