@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/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: "<leader>f", desc: "Search conversations", group: "Search", cmd: open }],
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
@@ -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
  }