@bojackduy/opencode-telescope 0.1.13 → 0.1.15
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/README.md +39 -1
- package/components/result-list.tsx +2 -2
- package/package.json +1 -1
- package/search.ts +93 -29
- package/telescope.tsx +424 -138
- package/tui.tsx +4 -2
- package/ui/config.test.ts +48 -0
- package/ui/config.ts +93 -0
- package/ui/keyboard.test.ts +40 -0
- package/ui/keyboard.ts +34 -0
- package/ui/render-target.ts +2 -0
package/tui.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
3
|
import { Telescope } from "./telescope.tsx"
|
|
4
|
+
import { loadTelescopeConfig } from "./ui/config.ts"
|
|
4
5
|
|
|
5
6
|
const id = "opencode-telescope"
|
|
6
7
|
|
|
@@ -13,9 +14,10 @@ const enabled = (options: unknown) => {
|
|
|
13
14
|
const tui: TuiPlugin = async (api: TuiPluginApi, options: unknown) => {
|
|
14
15
|
if (!enabled(options)) return
|
|
15
16
|
|
|
17
|
+
const config = loadTelescopeConfig()
|
|
16
18
|
const command = "opencode.telescope.sessions"
|
|
17
19
|
const open = () => {
|
|
18
|
-
api.ui.dialog.replace(() => <Telescope api={api} onClose={() => api.ui.dialog.clear()} />)
|
|
20
|
+
api.ui.dialog.replace(() => <Telescope api={api} config={config} onClose={() => api.ui.dialog.clear()} />)
|
|
19
21
|
api.ui.dialog.setSize("xlarge")
|
|
20
22
|
}
|
|
21
23
|
|
|
@@ -30,7 +32,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi, options: unknown) => {
|
|
|
30
32
|
run: open,
|
|
31
33
|
},
|
|
32
34
|
],
|
|
33
|
-
bindings: [{ key:
|
|
35
|
+
bindings: [{ key: config.openKey, desc: "Search conversations", group: "Search", cmd: open }],
|
|
34
36
|
})
|
|
35
37
|
|
|
36
38
|
api.lifecycle.onDispose(() => {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { defaultTelescopeConfig, loadTelescopeConfig, parseTelescopeConfig, telescopeConfigPath } from "./config.ts"
|
|
6
|
+
|
|
7
|
+
describe("telescope config", () => {
|
|
8
|
+
test("uses defaults for missing config", () => {
|
|
9
|
+
const dir = mkdtempSync(path.join(tmpdir(), "opencode-telescope-config-"))
|
|
10
|
+
try {
|
|
11
|
+
expect(loadTelescopeConfig(path.join(dir, "missing.json"))).toEqual(defaultTelescopeConfig)
|
|
12
|
+
} finally {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true })
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("uses defaults for invalid json", () => {
|
|
18
|
+
const dir = mkdtempSync(path.join(tmpdir(), "opencode-telescope-config-"))
|
|
19
|
+
const file = path.join(dir, "config.json")
|
|
20
|
+
try {
|
|
21
|
+
writeFileSync(file, "{")
|
|
22
|
+
expect(loadTelescopeConfig(file)).toEqual(defaultTelescopeConfig)
|
|
23
|
+
} finally {
|
|
24
|
+
rmSync(dir, { recursive: true, force: true })
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("merges valid partial config and ignores invalid fields", () => {
|
|
29
|
+
const config = parseTelescopeConfig({
|
|
30
|
+
openKey: " <leader>s ",
|
|
31
|
+
keys: {
|
|
32
|
+
moveDown: ["n", 1, ""],
|
|
33
|
+
moveUp: [],
|
|
34
|
+
open: "ctrl+o",
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(config.openKey).toBe("<leader>s")
|
|
39
|
+
expect(config.keys.moveDown).toEqual(["n"])
|
|
40
|
+
expect(config.keys.moveUp).toEqual(defaultTelescopeConfig.keys.moveUp)
|
|
41
|
+
expect(config.keys.open).toEqual(["ctrl+o"])
|
|
42
|
+
expect(config.keys.close).toEqual(defaultTelescopeConfig.keys.close)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("resolves config under XDG_CONFIG_HOME", () => {
|
|
46
|
+
expect(telescopeConfigPath({ XDG_CONFIG_HOME: "/tmp/config" })).toBe("/tmp/config/opencode/opencode-telescope/config.json")
|
|
47
|
+
})
|
|
48
|
+
})
|
package/ui/config.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
export type TelescopeKeyAction =
|
|
6
|
+
| "moveDown"
|
|
7
|
+
| "moveUp"
|
|
8
|
+
| "scrollPreviewDown"
|
|
9
|
+
| "scrollPreviewUp"
|
|
10
|
+
| "open"
|
|
11
|
+
| "close"
|
|
12
|
+
| "insertMode"
|
|
13
|
+
| "normalMode"
|
|
14
|
+
| "toggleOwner"
|
|
15
|
+
|
|
16
|
+
export type TelescopeConfig = {
|
|
17
|
+
openKey: string
|
|
18
|
+
keys: Record<TelescopeKeyAction, string[]>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const defaultTelescopeConfig: TelescopeConfig = {
|
|
22
|
+
openKey: "<leader>f",
|
|
23
|
+
keys: {
|
|
24
|
+
moveDown: ["down", "j"],
|
|
25
|
+
moveUp: ["up", "k"],
|
|
26
|
+
scrollPreviewDown: ["d"],
|
|
27
|
+
scrollPreviewUp: ["u"],
|
|
28
|
+
open: ["enter", "return"],
|
|
29
|
+
close: ["q", "escape"],
|
|
30
|
+
insertMode: ["/"],
|
|
31
|
+
normalMode: ["ctrl+q"],
|
|
32
|
+
toggleOwner: ["o"],
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function telescopeConfigPath(env: NodeJS.ProcessEnv = process.env) {
|
|
37
|
+
const configHome = env.XDG_CONFIG_HOME || path.join(homedir(), ".config")
|
|
38
|
+
return path.join(configHome, "opencode", "opencode-telescope", "config.json")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadTelescopeConfig(configPath = telescopeConfigPath()): TelescopeConfig {
|
|
42
|
+
if (!existsSync(configPath)) return cloneConfig(defaultTelescopeConfig)
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
return parseTelescopeConfig(JSON.parse(readFileSync(configPath, "utf8")))
|
|
46
|
+
} catch {
|
|
47
|
+
return cloneConfig(defaultTelescopeConfig)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseTelescopeConfig(value: unknown): TelescopeConfig {
|
|
52
|
+
const config = cloneConfig(defaultTelescopeConfig)
|
|
53
|
+
if (!isRecord(value)) return config
|
|
54
|
+
|
|
55
|
+
if (typeof value.openKey === "string" && value.openKey.trim()) {
|
|
56
|
+
config.openKey = value.openKey.trim()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!isRecord(value.keys)) return config
|
|
60
|
+
for (const action of Object.keys(defaultTelescopeConfig.keys) as TelescopeKeyAction[]) {
|
|
61
|
+
const parsed = parseKeyList(value.keys[action])
|
|
62
|
+
if (parsed) config.keys[action] = parsed
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return config
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseKeyList(value: unknown) {
|
|
69
|
+
if (typeof value === "string") {
|
|
70
|
+
const key = value.trim()
|
|
71
|
+
return key ? [key] : undefined
|
|
72
|
+
}
|
|
73
|
+
if (!Array.isArray(value)) return
|
|
74
|
+
|
|
75
|
+
const keys = value
|
|
76
|
+
.filter((item): item is string => typeof item === "string")
|
|
77
|
+
.map((item) => item.trim())
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
return keys.length > 0 ? keys : undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cloneConfig(config: TelescopeConfig): TelescopeConfig {
|
|
83
|
+
return {
|
|
84
|
+
openKey: config.openKey,
|
|
85
|
+
keys: Object.fromEntries(
|
|
86
|
+
Object.entries(config.keys).map(([action, keys]) => [action, [...keys]]),
|
|
87
|
+
) as Record<TelescopeKeyAction, string[]>,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
92
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value))
|
|
93
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ParsedKey } from "@opentui/core"
|
|
2
|
+
import { describe, expect, test } from "bun:test"
|
|
3
|
+
import { inputSafeKeys, keyListLabel, matchesKey } from "./keyboard.ts"
|
|
4
|
+
|
|
5
|
+
describe("keyboard helpers", () => {
|
|
6
|
+
test("matches simple key names", () => {
|
|
7
|
+
expect(matchesKey(key("j"), ["j"])).toBe(true)
|
|
8
|
+
expect(matchesKey(key("j"), ["k"])).toBe(false)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("matches ctrl modifier strings", () => {
|
|
12
|
+
expect(matchesKey(key("q", { ctrl: true }), ["ctrl+q"])).toBe(true)
|
|
13
|
+
expect(matchesKey(key("q"), ["ctrl+q"])).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("labels configured key lists", () => {
|
|
17
|
+
expect(keyListLabel(["ctrl+q"])).toBe("^q")
|
|
18
|
+
expect(keyListLabel(["enter", "return"])).toBe("enter/return")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("filters text input unsafe plain character keys", () => {
|
|
22
|
+
expect(inputSafeKeys(["j", "down", "ctrl+j"])).toEqual(["down", "ctrl+j"])
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function key(name: string, options: Partial<ParsedKey> = {}): ParsedKey {
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
ctrl: false,
|
|
30
|
+
meta: false,
|
|
31
|
+
shift: false,
|
|
32
|
+
option: false,
|
|
33
|
+
sequence: name,
|
|
34
|
+
number: false,
|
|
35
|
+
raw: name,
|
|
36
|
+
eventType: "press",
|
|
37
|
+
source: "raw",
|
|
38
|
+
...options,
|
|
39
|
+
}
|
|
40
|
+
}
|
package/ui/keyboard.ts
CHANGED
|
@@ -4,6 +4,40 @@ export function isKey(evt: ParsedKey, ...names: string[]) {
|
|
|
4
4
|
return names.includes(evt.name)
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
export function matchesKey(evt: ParsedKey, keys: string[]) {
|
|
8
|
+
return keys.some((key) => matchesKeyString(evt, key))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function keyListLabel(keys: string[]) {
|
|
12
|
+
return keys.map(keyLabel).join("/")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function inputSafeKeys(keys: string[]) {
|
|
16
|
+
return keys.filter((key) => key.includes("+") || key.length > 1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function matchesKeyString(evt: ParsedKey, key: string) {
|
|
20
|
+
const parts = key.toLowerCase().split("+").map((part) => part.trim()).filter(Boolean)
|
|
21
|
+
const name = parts.at(-1)
|
|
22
|
+
if (!name || evt.name.toLowerCase() !== name) return false
|
|
23
|
+
|
|
24
|
+
const modifiers = new Set(parts.slice(0, -1))
|
|
25
|
+
return Boolean(evt.ctrl) === modifiers.has("ctrl") &&
|
|
26
|
+
Boolean(evt.meta) === modifiers.has("meta") &&
|
|
27
|
+
Boolean(evt.shift) === modifiers.has("shift") &&
|
|
28
|
+
Boolean(evt.option) === (modifiers.has("alt") || modifiers.has("option"))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function keyLabel(key: string) {
|
|
32
|
+
return key.split("+")
|
|
33
|
+
.map((part) => {
|
|
34
|
+
const value = part.trim()
|
|
35
|
+
if (value.toLowerCase() === "ctrl") return "^"
|
|
36
|
+
return value
|
|
37
|
+
})
|
|
38
|
+
.join("")
|
|
39
|
+
}
|
|
40
|
+
|
|
7
41
|
export function prevent(evt: ParsedKey) {
|
|
8
42
|
const controlled = evt as ParsedKey & {
|
|
9
43
|
preventDefault?: () => void
|
package/ui/render-target.ts
CHANGED
|
@@ -6,6 +6,8 @@ export function previewScrollAmount(scroll: ScrollBoxRenderable | undefined) {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export function messageTargetID(item: SearchResult) {
|
|
9
|
+
if (item.partType === "tool") return `tool-inline-${item.messageID}-${item.id}`
|
|
10
|
+
if (item.partType === "reasoning") return `text-${item.messageID}-${item.id}`
|
|
9
11
|
if (item.role === "assistant") return `text-${item.messageID}-${item.id}`
|
|
10
12
|
return item.messageID
|
|
11
13
|
}
|