@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/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
+ }