opencode-vim 0.0.23 → 0.0.24
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 +11 -0
- package/dist/tui.js +3326 -0
- package/package.json +6 -6
- package/src/clipboard.tsx +0 -50
- package/src/modules/snippets/index.tsx +0 -17
- package/src/modules/snippets/loader.ts +0 -147
- package/src/modules/snippets/search.ts +0 -118
- package/src/modules/snippets/skill-loader.ts +0 -81
- package/src/modules/snippets/state.ts +0 -40
- package/src/modules/snippets/trigger.ts +0 -68
- package/src/modules/snippets/types.ts +0 -50
- package/src/modules/snippets/view.tsx +0 -674
- package/src/modules/vim/actions.ts +0 -82
- package/src/modules/vim/config.ts +0 -137
- package/src/modules/vim/edit.test.ts +0 -51
- package/src/modules/vim/edit.ts +0 -33
- package/src/modules/vim/graphemes.ts +0 -38
- package/src/modules/vim/index.tsx +0 -200
- package/src/modules/vim/keys.ts +0 -38
- package/src/modules/vim/log.ts +0 -37
- package/src/modules/vim/map.test.ts +0 -45
- package/src/modules/vim/map.ts +0 -90
- package/src/modules/vim/state.test.ts +0 -18
- package/src/modules/vim/state.tsx +0 -46
- package/src/modules/vim/view.tsx +0 -130
- package/src/modules/vim/vimee.test.ts +0 -211
- package/src/modules/vim/vimee.ts +0 -1211
- package/src/plugin.tsx +0 -116
- package/src/prompt/host.tsx +0 -38
- package/src/prompt/modules.ts +0 -16
- package/src/prompt/root.tsx +0 -69
- package/src/prompt/types.ts +0 -42
- package/src/session.tsx +0 -291
- package/src/transcript-items.tsx +0 -168
- package/src/transcript.tsx +0 -179
- package/src/update.ts +0 -120
- package/tui.tsx +0 -340
- package/view.tsx +0 -29
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "opencode-vim",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.24",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "https://github.com/Tarquinen/opencode-vim"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
|
-
"./tui": "./tui.
|
|
11
|
+
"./tui": "./dist/tui.js"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
+
"build": "bun scripts/build.ts",
|
|
15
|
+
"prepack": "bun run build",
|
|
14
16
|
"typecheck": "tsc --noEmit",
|
|
15
17
|
"test": "bun test --conditions=browser --preload @opentui/solid/preload",
|
|
16
18
|
"bench": "bun test/benchmark.ts"
|
|
@@ -33,9 +35,7 @@
|
|
|
33
35
|
"files": [
|
|
34
36
|
"assets/",
|
|
35
37
|
"docs/",
|
|
36
|
-
"
|
|
37
|
-
"README.md"
|
|
38
|
-
"tui.tsx",
|
|
39
|
-
"view.tsx"
|
|
38
|
+
"dist/",
|
|
39
|
+
"README.md"
|
|
40
40
|
]
|
|
41
41
|
}
|
package/src/clipboard.tsx
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
// .tsx lets OpenCode's runtime loader supply its shared OpenTUI imports.
|
|
2
|
-
import { createClipboard, createHostClipboard, createRendererClipboardAdapter, type RendererClipboardBoundary } from "@opentui/core"
|
|
3
|
-
|
|
4
|
-
export type VimClipboard = ReturnType<typeof createVimClipboard>
|
|
5
|
-
|
|
6
|
-
export function createVimClipboard(renderer: RendererClipboardBoundary) {
|
|
7
|
-
const terminal = createRendererClipboardAdapter(renderer)
|
|
8
|
-
const clipboard = createClipboard({ host: createHostClipboard(), terminal })
|
|
9
|
-
let fallback = ""
|
|
10
|
-
let revision = 0
|
|
11
|
-
let writes = Promise.resolve()
|
|
12
|
-
let disposed = false
|
|
13
|
-
|
|
14
|
-
return {
|
|
15
|
-
write(text: string): Promise<boolean> {
|
|
16
|
-
text = text.replaceAll("\0", "")
|
|
17
|
-
fallback = text
|
|
18
|
-
revision++
|
|
19
|
-
// Keep rapid cuts in order, and let an immediate p wait for the last write.
|
|
20
|
-
const result = writes.then(async () => {
|
|
21
|
-
if (disposed) return false
|
|
22
|
-
try {
|
|
23
|
-
const result = await clipboard.writeText(text, { destination: "all-available", selection: "clipboard" })
|
|
24
|
-
return result.host.status === "written" || result.terminal.status === "attempted"
|
|
25
|
-
} catch { return false }
|
|
26
|
-
})
|
|
27
|
-
writes = result.then(() => {})
|
|
28
|
-
return result
|
|
29
|
-
},
|
|
30
|
-
async read(): Promise<string> {
|
|
31
|
-
await writes
|
|
32
|
-
// OSC52 supports copying, but OpenTUI cannot read the client's clipboard.
|
|
33
|
-
if (disposed || terminal.remote) return fallback
|
|
34
|
-
const before = revision
|
|
35
|
-
try {
|
|
36
|
-
const result = await clipboard.read({ preferredTypes: ["text/plain"], selection: "clipboard" })
|
|
37
|
-
if (before !== revision) return fallback
|
|
38
|
-
if (result.status === "empty") fallback = ""
|
|
39
|
-
else if (result.status === "read" && result.representation.mimeType === "text/plain") {
|
|
40
|
-
fallback = new TextDecoder().decode(result.representation.bytes).replace(/\r\n?/g, "\n").replaceAll("\0", "")
|
|
41
|
-
}
|
|
42
|
-
} catch {}
|
|
43
|
-
return fallback
|
|
44
|
-
},
|
|
45
|
-
dispose() {
|
|
46
|
-
disposed = true
|
|
47
|
-
return clipboard.dispose()
|
|
48
|
-
},
|
|
49
|
-
}
|
|
50
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import type { PromptModule } from "../../prompt/types"
|
|
3
|
-
import type { SnippetController } from "./types"
|
|
4
|
-
import { SnippetAutocomplete } from "./view"
|
|
5
|
-
|
|
6
|
-
export function createSnippetsModule(controller: SnippetController): PromptModule {
|
|
7
|
-
return {
|
|
8
|
-
id: "snippets",
|
|
9
|
-
order: -10,
|
|
10
|
-
onSubmit() {
|
|
11
|
-
return controller.accept?.() === true
|
|
12
|
-
},
|
|
13
|
-
renderAbove(ctx) {
|
|
14
|
-
return <SnippetAutocomplete ctx={ctx} controller={controller} />
|
|
15
|
-
},
|
|
16
|
-
}
|
|
17
|
-
}
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import { basename, join } from "node:path"
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises"
|
|
3
|
-
import type { SnippetInfo, SnippetSource } from "./types"
|
|
4
|
-
|
|
5
|
-
const SNIPPET_EXT = ".md"
|
|
6
|
-
const EMPTY_SNIPPET = `---
|
|
7
|
-
description: ""
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
`
|
|
11
|
-
|
|
12
|
-
export async function loadSnippets(projectDir?: string) {
|
|
13
|
-
const registry = new Map<string, SnippetInfo>()
|
|
14
|
-
const home = process.env.HOME
|
|
15
|
-
|
|
16
|
-
if (home) {
|
|
17
|
-
await loadDir(registry, join(home, ".config/opencode/snippets"), "global")
|
|
18
|
-
await loadDir(registry, join(home, ".config/opencode/snippet"), "global")
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
if (projectDir) {
|
|
22
|
-
await loadDir(registry, join(projectDir, ".opencode/snippets"), "project")
|
|
23
|
-
await loadDir(registry, join(projectDir, ".opencode/snippet"), "project")
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return listSnippets(registry).sort((left, right) => sourceRank(left) - sourceRank(right) || left.name.localeCompare(right.name))
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function loadDir(registry: Map<string, SnippetInfo>, dir: string, source: SnippetSource) {
|
|
30
|
-
let entries: string[]
|
|
31
|
-
try {
|
|
32
|
-
entries = await Array.fromAsync(new Bun.Glob(`*${SNIPPET_EXT}`).scan({ cwd: dir, onlyFiles: true }))
|
|
33
|
-
} catch {
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
for (const entry of entries) {
|
|
38
|
-
const snippet = await loadFile(join(dir, entry), source)
|
|
39
|
-
if (snippet) registerSnippet(registry, snippet)
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function loadFile(filePath: string, source: SnippetSource) {
|
|
44
|
-
let raw: string
|
|
45
|
-
try {
|
|
46
|
-
raw = await Bun.file(filePath).text()
|
|
47
|
-
} catch {
|
|
48
|
-
return undefined
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const parsed = parseFrontmatter(raw)
|
|
52
|
-
return {
|
|
53
|
-
name: basename(filePath, SNIPPET_EXT),
|
|
54
|
-
content: parsed.content.trim(),
|
|
55
|
-
aliases: normalizeAliases(parsed.data.aliases ?? parsed.data.alias),
|
|
56
|
-
description: typeof parsed.data.description === "string" ? parsed.data.description : undefined,
|
|
57
|
-
filePath,
|
|
58
|
-
source,
|
|
59
|
-
} satisfies SnippetInfo
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function ensureSnippetDraft(name: string, projectDir?: string) {
|
|
63
|
-
const dir = await ensureSnippetsDir(projectDir)
|
|
64
|
-
const filePath = join(dir, `${name}${SNIPPET_EXT}`)
|
|
65
|
-
if (!(await Bun.file(filePath).exists())) await writeFile(filePath, EMPTY_SNIPPET)
|
|
66
|
-
return filePath
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function ensureSnippetsDir(projectDir?: string) {
|
|
70
|
-
const home = process.env.HOME
|
|
71
|
-
const dir = projectDir ? join(projectDir, ".opencode/snippet") : home ? join(home, ".config/opencode/snippet") : undefined
|
|
72
|
-
if (!dir) throw new Error("Unable to resolve snippets directory")
|
|
73
|
-
await mkdir(dir, { recursive: true })
|
|
74
|
-
return dir
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function parseFrontmatter(raw: string) {
|
|
78
|
-
if (!raw.startsWith("---\n")) return { data: {} as Record<string, unknown>, content: raw }
|
|
79
|
-
|
|
80
|
-
const end = raw.indexOf("\n---", 4)
|
|
81
|
-
if (end < 0) return { data: {} as Record<string, unknown>, content: raw }
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
data: parseYamlish(raw.slice(4, end)),
|
|
85
|
-
content: raw.slice(end + 4).replace(/^\r?\n/, ""),
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function parseYamlish(input: string) {
|
|
90
|
-
const data: Record<string, unknown> = {}
|
|
91
|
-
const lines = input.split(/\r?\n/)
|
|
92
|
-
let currentList: string | undefined
|
|
93
|
-
|
|
94
|
-
for (const line of lines) {
|
|
95
|
-
const listItem = /^\s*-\s*(.+)$/.exec(line)
|
|
96
|
-
if (listItem && currentList) {
|
|
97
|
-
const value = String(listItem[1]).trim()
|
|
98
|
-
data[currentList] = [...asArray(data[currentList]), unquote(value)]
|
|
99
|
-
continue
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
|
|
103
|
-
if (!pair) continue
|
|
104
|
-
|
|
105
|
-
currentList = undefined
|
|
106
|
-
const key = pair[1]
|
|
107
|
-
const value = pair[2].trim()
|
|
108
|
-
if (!value) {
|
|
109
|
-
data[key] = []
|
|
110
|
-
currentList = key
|
|
111
|
-
} else {
|
|
112
|
-
data[key] = unquote(value)
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return data
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function normalizeAliases(value: unknown) {
|
|
120
|
-
return asArray(value).filter((entry) => typeof entry === "string" && entry.length > 0) as string[]
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function asArray(value: unknown) {
|
|
124
|
-
return Array.isArray(value) ? value : typeof value === "string" ? [value] : []
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function unquote(value: string) {
|
|
128
|
-
return value.replace(/^['"]|['"]$/g, "")
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function registerSnippet(registry: Map<string, SnippetInfo>, snippet: SnippetInfo) {
|
|
132
|
-
const old = registry.get(snippet.name.toLowerCase())
|
|
133
|
-
if (old) {
|
|
134
|
-
for (const alias of old.aliases) registry.delete(alias.toLowerCase())
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
registry.set(snippet.name.toLowerCase(), snippet)
|
|
138
|
-
for (const alias of snippet.aliases) registry.set(alias.toLowerCase(), snippet)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function listSnippets(registry: Map<string, SnippetInfo>) {
|
|
142
|
-
return [...new Map([...registry.values()].map((snippet) => [snippet.name, snippet])).values()]
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function sourceRank(snippet: SnippetInfo) {
|
|
146
|
-
return snippet.source === "project" ? 0 : 1
|
|
147
|
-
}
|
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import type { SkillInfo, SnippetInfo } from "./types"
|
|
2
|
-
|
|
3
|
-
export type HighlightPart = {
|
|
4
|
-
text: string
|
|
5
|
-
match: boolean
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
function normalizeSearchText(input: string) {
|
|
9
|
-
return input.toLowerCase().replace(/[^a-z0-9]/g, "")
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function scoreText(input: string, query: string) {
|
|
13
|
-
const raw = input.toLowerCase()
|
|
14
|
-
const compact = normalizeSearchText(input)
|
|
15
|
-
const needle = query.toLowerCase().trim()
|
|
16
|
-
const compactNeedle = normalizeSearchText(query)
|
|
17
|
-
|
|
18
|
-
if (raw === needle) return 0
|
|
19
|
-
if (compactNeedle && compact === compactNeedle) return 1
|
|
20
|
-
if (raw.startsWith(needle)) return 2
|
|
21
|
-
if (compactNeedle && compact.startsWith(compactNeedle)) return 3
|
|
22
|
-
if (raw.includes(needle)) return 4
|
|
23
|
-
if (compactNeedle && compact.includes(compactNeedle)) return 5
|
|
24
|
-
return Number.POSITIVE_INFINITY
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function snippetDescription(snippet: SnippetInfo) {
|
|
28
|
-
return (snippet.description || snippet.content).replace(/\s+/g, " ").trim()
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function scoreSnippet(snippet: SnippetInfo, query: string) {
|
|
32
|
-
if (!query) return 0
|
|
33
|
-
|
|
34
|
-
const nameScore = Math.min(scoreText(snippet.name, query), ...snippet.aliases.map((alias) => scoreText(alias, query)))
|
|
35
|
-
if (Number.isFinite(nameScore)) return nameScore
|
|
36
|
-
|
|
37
|
-
const description = (snippet.description || "").replace(/\s+/g, " ").trim().toLowerCase()
|
|
38
|
-
const lowerQuery = query.toLowerCase()
|
|
39
|
-
if (description.startsWith(lowerQuery)) return 6
|
|
40
|
-
if (description.includes(lowerQuery)) return 7
|
|
41
|
-
return Number.POSITIVE_INFINITY
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function sourceRank(snippet: SnippetInfo) {
|
|
45
|
-
return snippet.source === "project" ? 0 : 1
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function filterSnippets(snippets: SnippetInfo[], query: string) {
|
|
49
|
-
const needle = query.trim()
|
|
50
|
-
return snippets
|
|
51
|
-
.map((snippet) => ({ snippet, score: scoreSnippet(snippet, needle) }))
|
|
52
|
-
.filter((entry) => Number.isFinite(entry.score))
|
|
53
|
-
.sort((left, right) => left.score - right.score || sourceRank(left.snippet) - sourceRank(right.snippet) || left.snippet.name.localeCompare(right.snippet.name))
|
|
54
|
-
.map((entry) => entry.snippet)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function matchedAliases(snippet: SnippetInfo, query: string) {
|
|
58
|
-
const needle = query.trim()
|
|
59
|
-
if (!needle) return []
|
|
60
|
-
return snippet.aliases.filter((alias) => Number.isFinite(scoreText(alias, needle)))
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function describeSnippet(snippet: SnippetInfo) {
|
|
64
|
-
return snippetDescription(snippet)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function highlightMatches(input: string, query: string): HighlightPart[] {
|
|
68
|
-
const needle = query.trim().toLowerCase()
|
|
69
|
-
if (!needle) return [{ text: input, match: false }]
|
|
70
|
-
|
|
71
|
-
const lower = input.toLowerCase()
|
|
72
|
-
const parts: HighlightPart[] = []
|
|
73
|
-
let cursor = 0
|
|
74
|
-
|
|
75
|
-
while (cursor < input.length) {
|
|
76
|
-
const index = lower.indexOf(needle, cursor)
|
|
77
|
-
if (index < 0) break
|
|
78
|
-
if (index > cursor) parts.push({ text: input.slice(cursor, index), match: false })
|
|
79
|
-
parts.push({ text: input.slice(index, index + needle.length), match: true })
|
|
80
|
-
cursor = index + needle.length
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (parts.length === 0) return [{ text: input, match: false }]
|
|
84
|
-
if (cursor < input.length) parts.push({ text: input.slice(cursor), match: false })
|
|
85
|
-
return parts
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function skillTag(skill: SkillInfo) {
|
|
89
|
-
return `skill(${skill.name})`
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function scoreSkill(skill: SkillInfo, query: string) {
|
|
93
|
-
if (!query) return 0
|
|
94
|
-
const nameScore = Math.min(scoreText(skill.name, query), scoreText(skillTag(skill), query))
|
|
95
|
-
if (Number.isFinite(nameScore)) return nameScore
|
|
96
|
-
const description = (skill.description || "").replace(/\s+/g, " ").trim().toLowerCase()
|
|
97
|
-
const lowerQuery = query.toLowerCase()
|
|
98
|
-
if (description.startsWith(lowerQuery)) return 6
|
|
99
|
-
if (description.includes(lowerQuery)) return 7
|
|
100
|
-
return Number.POSITIVE_INFINITY
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function skillSourceRank(skill: SkillInfo) {
|
|
104
|
-
return skill.source === "project" ? 0 : 1
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function filterSkills(skills: SkillInfo[], query: string) {
|
|
108
|
-
const needle = query.trim()
|
|
109
|
-
return skills
|
|
110
|
-
.map((skill) => ({ skill, score: scoreSkill(skill, needle) }))
|
|
111
|
-
.filter((entry) => Number.isFinite(entry.score))
|
|
112
|
-
.sort((left, right) => left.score - right.score || skillSourceRank(left.skill) - skillSourceRank(right.skill) || left.skill.name.localeCompare(right.skill.name))
|
|
113
|
-
.map((entry) => entry.skill)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function describeSkill(skill: SkillInfo) {
|
|
117
|
-
return (skill.description || skill.content).replace(/\s+/g, " ").trim()
|
|
118
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { dirname, join, parse } from "node:path"
|
|
2
|
-
import type { SkillInfo, SnippetSource } from "./types"
|
|
3
|
-
import { parseFrontmatter } from "./loader"
|
|
4
|
-
|
|
5
|
-
const SKILL_FILE = "SKILL.md"
|
|
6
|
-
|
|
7
|
-
export async function loadSkills(projectDir?: string) {
|
|
8
|
-
const registry = new Map<string, SkillInfo>()
|
|
9
|
-
const home = process.env.HOME
|
|
10
|
-
|
|
11
|
-
if (home) {
|
|
12
|
-
await loadSkillDir(registry, join(home, ".config/opencode/skill"), "global")
|
|
13
|
-
await loadSkillDir(registry, join(home, ".config/opencode/skills"), "global")
|
|
14
|
-
await loadSkillDir(registry, join(home, ".claude/skills"), "global")
|
|
15
|
-
await loadSkillDir(registry, join(home, ".agents/skills"), "global")
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
for (const root of await projectRoots(projectDir)) {
|
|
19
|
-
await loadSkillDir(registry, join(root, ".opencode/skill"), "project")
|
|
20
|
-
await loadSkillDir(registry, join(root, ".opencode/skills"), "project")
|
|
21
|
-
await loadSkillDir(registry, join(root, ".claude/skills"), "project")
|
|
22
|
-
await loadSkillDir(registry, join(root, ".agents/skills"), "project")
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return [...registry.values()].sort((left, right) => sourceRank(left) - sourceRank(right) || left.name.localeCompare(right.name))
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function projectRoots(projectDir?: string) {
|
|
29
|
-
if (!projectDir) return []
|
|
30
|
-
const roots: string[] = []
|
|
31
|
-
let current = projectDir
|
|
32
|
-
|
|
33
|
-
while (true) {
|
|
34
|
-
roots.push(current)
|
|
35
|
-
if (await Bun.file(join(current, ".git")).exists()) break
|
|
36
|
-
const parent = dirname(current)
|
|
37
|
-
if (parent === current) break
|
|
38
|
-
current = parent
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
return roots.reverse()
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function loadSkillDir(registry: Map<string, SkillInfo>, dir: string, source: SnippetSource) {
|
|
45
|
-
let entries: string[]
|
|
46
|
-
try {
|
|
47
|
-
entries = await Array.fromAsync(new Bun.Glob(`*/${SKILL_FILE}`).scan({ cwd: dir, onlyFiles: true }))
|
|
48
|
-
} catch {
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
for (const entry of entries) {
|
|
53
|
-
const skill = await loadSkill(join(dir, entry), source)
|
|
54
|
-
if (skill) registry.set(skill.name.toLowerCase(), skill)
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function loadSkill(filePath: string, source: SnippetSource) {
|
|
59
|
-
let raw: string
|
|
60
|
-
try {
|
|
61
|
-
raw = await Bun.file(filePath).text()
|
|
62
|
-
} catch {
|
|
63
|
-
return undefined
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const parsed = parseFrontmatter(raw)
|
|
67
|
-
const folderName = parse(dirname(filePath)).base
|
|
68
|
-
const name = typeof parsed.data.name === "string" && parsed.data.name.trim() ? parsed.data.name.trim() : folderName
|
|
69
|
-
|
|
70
|
-
return {
|
|
71
|
-
name,
|
|
72
|
-
content: parsed.content.trim(),
|
|
73
|
-
description: typeof parsed.data.description === "string" ? parsed.data.description : undefined,
|
|
74
|
-
filePath,
|
|
75
|
-
source,
|
|
76
|
-
} satisfies SkillInfo
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function sourceRank(skill: SkillInfo) {
|
|
80
|
-
return skill.source === "project" ? 0 : 1
|
|
81
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { dirname, join } from "node:path"
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises"
|
|
3
|
-
|
|
4
|
-
const SCOPE_GLOBAL = "__global__"
|
|
5
|
-
|
|
6
|
-
export async function addPendingDraft(workspaceDir: string | undefined, name: string) {
|
|
7
|
-
const filePath = statePath("pending-drafts.json")
|
|
8
|
-
const state = await readState(filePath)
|
|
9
|
-
const scope = workspaceDir || SCOPE_GLOBAL
|
|
10
|
-
const current = Array.isArray(state[scope]) ? state[scope].filter((item): item is string => typeof item === "string") : []
|
|
11
|
-
const next = new Set([...current, name.toLowerCase()])
|
|
12
|
-
state[scope] = [...next].sort()
|
|
13
|
-
await writeState(filePath, state)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export async function markSnippetReloadRequested(workspaceDir?: string) {
|
|
17
|
-
const filePath = statePath("snippet-reload.json")
|
|
18
|
-
const state = await readState(filePath)
|
|
19
|
-
state[workspaceDir || SCOPE_GLOBAL] = Date.now()
|
|
20
|
-
await writeState(filePath, state)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function statePath(name: string) {
|
|
24
|
-
const home = process.env.HOME
|
|
25
|
-
if (!home) throw new Error("HOME is not set")
|
|
26
|
-
return join(home, ".config/opencode/state", name)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function readState(filePath: string) {
|
|
30
|
-
try {
|
|
31
|
-
const parsed = JSON.parse(await Bun.file(filePath).text())
|
|
32
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as Record<string, unknown>
|
|
33
|
-
} catch {}
|
|
34
|
-
return {} as Record<string, unknown>
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function writeState(filePath: string, state: Record<string, unknown>) {
|
|
38
|
-
await mkdir(dirname(filePath), { recursive: true })
|
|
39
|
-
await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`)
|
|
40
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import type { HashtagTriggerMatch, SnippetInfo } from "./types"
|
|
2
|
-
|
|
3
|
-
export function findTrailingHashtagTrigger(input: string): HashtagTriggerMatch | undefined {
|
|
4
|
-
let start = -1
|
|
5
|
-
for (let i = input.length - 1; i >= 0; i--) {
|
|
6
|
-
const char = input[i]
|
|
7
|
-
if (char === "#") {
|
|
8
|
-
start = i
|
|
9
|
-
break
|
|
10
|
-
}
|
|
11
|
-
if (/\s/.test(char)) return undefined
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
if (start < 0) return undefined
|
|
15
|
-
if (start > 0 && !/\s/.test(input[start - 1])) return undefined
|
|
16
|
-
|
|
17
|
-
const query = input.slice(start + 1)
|
|
18
|
-
const token = `#${query}`
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
start,
|
|
22
|
-
end: input.length,
|
|
23
|
-
query,
|
|
24
|
-
token,
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function replaceTrailingHashtag(input: string, name: string) {
|
|
29
|
-
const match = findTrailingHashtagTrigger(input)
|
|
30
|
-
if (!match) return input
|
|
31
|
-
return `${input.slice(0, match.start)}#${name} `
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function insertSnippetTag(input: string, name: string) {
|
|
35
|
-
const match = findTrailingHashtagTrigger(input)
|
|
36
|
-
if (match) return replaceTrailingHashtag(input, name)
|
|
37
|
-
const separator = input.length === 0 || /\s$/.test(input) ? "" : " "
|
|
38
|
-
return `${input}${separator}#${name} `
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function insertSkillLoad(input: string, name: string) {
|
|
42
|
-
const tag = `skill(${name})`
|
|
43
|
-
const match = findTrailingHashtagTrigger(input)
|
|
44
|
-
if (match) return replaceTrailingHashtag(input, tag)
|
|
45
|
-
const separator = input.length === 0 || /\s$/.test(input) ? "" : " "
|
|
46
|
-
return `${input}${separator}#${tag} `
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function preferredSnippetTag(input: string, snippet: SnippetInfo) {
|
|
50
|
-
const query = findTrailingHashtagTrigger(input)?.query
|
|
51
|
-
if (query && snippet.aliases.some((alias) => alias === query)) return query
|
|
52
|
-
return snippet.name
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function insertSnippetTrigger(input: string) {
|
|
56
|
-
if (findTrailingHashtagTrigger(input)) return input
|
|
57
|
-
const separator = input.length === 0 || /\s$/.test(input) ? "" : " "
|
|
58
|
-
return `${input}${separator}#`
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function isReloadCommand(input: string) {
|
|
62
|
-
return input.trim() === "/snippets:reload"
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function stepSelection(current: number, total: number, delta: number) {
|
|
66
|
-
if (total <= 0) return 0
|
|
67
|
-
return Math.max(0, Math.min(total - 1, current + delta))
|
|
68
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
export type SnippetSource = "global" | "project"
|
|
2
|
-
|
|
3
|
-
export type SnippetInfo = {
|
|
4
|
-
name: string
|
|
5
|
-
content: string
|
|
6
|
-
aliases: string[]
|
|
7
|
-
description?: string
|
|
8
|
-
filePath: string
|
|
9
|
-
source: SnippetSource
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export type SkillInfo = {
|
|
13
|
-
name: string
|
|
14
|
-
content: string
|
|
15
|
-
description?: string
|
|
16
|
-
filePath: string
|
|
17
|
-
source: SnippetSource
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type AutocompleteItem =
|
|
21
|
-
| {
|
|
22
|
-
kind: "snippet"
|
|
23
|
-
id: string
|
|
24
|
-
label: string
|
|
25
|
-
description: string
|
|
26
|
-
aliases: string[]
|
|
27
|
-
snippet: SnippetInfo
|
|
28
|
-
}
|
|
29
|
-
| {
|
|
30
|
-
kind: "skill"
|
|
31
|
-
id: string
|
|
32
|
-
label: string
|
|
33
|
-
description: string
|
|
34
|
-
aliases: string[]
|
|
35
|
-
skill: SkillInfo
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export type HashtagTriggerMatch = {
|
|
39
|
-
start: number
|
|
40
|
-
end: number
|
|
41
|
-
query: string
|
|
42
|
-
token: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export type SnippetController = {
|
|
46
|
-
accept?: () => boolean
|
|
47
|
-
reload?: () => void
|
|
48
|
-
insertTrigger?: () => void
|
|
49
|
-
navigate?: (delta: number) => boolean
|
|
50
|
-
}
|