opencode-vim 0.0.22 → 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 +25 -63
- package/assets/vim-in-motion-dialogs.gif +0 -0
- package/dist/tui.js +3326 -0
- package/docs/configuration.md +50 -400
- package/docs/keymap-actions.md +60 -188
- package/docs/vim-behavior.md +93 -0
- package/package.json +6 -6
- 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 -85
- package/src/modules/vim/config.ts +0 -125
- 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 -198
- 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 -1146
- 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/update.ts +0 -120
- package/tui.tsx +0 -275
- package/view.tsx +0 -29
package/src/plugin.tsx
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
-
import { createSignal } from "solid-js"
|
|
4
|
-
import { PromptRoot } from "./prompt/root"
|
|
5
|
-
import type { PromptModule } from "./prompt/types"
|
|
6
|
-
import { createVimModule } from "./modules/vim"
|
|
7
|
-
import { createSnippetsModule } from "./modules/snippets"
|
|
8
|
-
import type { SnippetController } from "./modules/snippets/types"
|
|
9
|
-
import { checkAutoUpdate } from "./update"
|
|
10
|
-
|
|
11
|
-
const tui: TuiPlugin = async (api, options, meta) => {
|
|
12
|
-
const vimEnabledKey = `${meta.id}:enabled`
|
|
13
|
-
const [vimEnabled, setVimEnabled] = createSignal(api.kv.get(vimEnabledKey, true) === true)
|
|
14
|
-
|
|
15
|
-
api.keymap.registerLayer({
|
|
16
|
-
commands: [
|
|
17
|
-
{
|
|
18
|
-
namespace: "palette",
|
|
19
|
-
name: "opencode-vim.toggle",
|
|
20
|
-
title: "Toggle Vim Mode",
|
|
21
|
-
desc: "Enable or disable Vim key handling",
|
|
22
|
-
category: "Vim",
|
|
23
|
-
slashName: "vim",
|
|
24
|
-
run() {
|
|
25
|
-
const next = !vimEnabled()
|
|
26
|
-
setVimEnabled(next)
|
|
27
|
-
api.kv.set(vimEnabledKey, next)
|
|
28
|
-
api.ui.toast({ variant: "info", message: `Vim mode ${next ? "enabled" : "disabled"}` })
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
],
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
if (readAutoUpdate(options)) {
|
|
35
|
-
let timer: Timer | undefined
|
|
36
|
-
api.lifecycle.onDispose(() => {
|
|
37
|
-
if (timer) clearTimeout(timer)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
void checkAutoUpdate(meta, api.lifecycle.signal).then((result) => {
|
|
41
|
-
if (!result.updated) return
|
|
42
|
-
|
|
43
|
-
timer = setTimeout(() => {
|
|
44
|
-
api.ui.toast({ variant: "info", message: "Updating opencode-vim. Restart OpenCode to finish." })
|
|
45
|
-
}, 5000)
|
|
46
|
-
}).catch(() => {})
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const moduleCache = new Map<string, PromptModule[]>()
|
|
50
|
-
const createModules = (key: string) => {
|
|
51
|
-
const cached = moduleCache.get(key)
|
|
52
|
-
if (cached) return cached
|
|
53
|
-
|
|
54
|
-
const modules: PromptModule[] = []
|
|
55
|
-
const snippets: SnippetController = {}
|
|
56
|
-
if (hasSnippetsPlugin(api)) modules.push(createSnippetsModule(snippets))
|
|
57
|
-
modules.push(createVimModule(options, vimEnabled, snippets))
|
|
58
|
-
moduleCache.set(key, modules)
|
|
59
|
-
return modules
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
api.slots.register({
|
|
63
|
-
order: 50,
|
|
64
|
-
slots: {
|
|
65
|
-
home_prompt(ctx, props) {
|
|
66
|
-
return <PromptRoot api={api} slot={ctx} kind="home" workspaceID={props.workspace_id} ref={props.ref} modules={createModules(`home:${props.workspace_id ?? ""}`)} />
|
|
67
|
-
},
|
|
68
|
-
session_prompt(ctx, props) {
|
|
69
|
-
return (
|
|
70
|
-
<PromptRoot
|
|
71
|
-
api={api}
|
|
72
|
-
slot={ctx}
|
|
73
|
-
kind="session"
|
|
74
|
-
sessionID={props.session_id}
|
|
75
|
-
visible={props.visible}
|
|
76
|
-
disabled={props.disabled}
|
|
77
|
-
onSubmit={props.on_submit}
|
|
78
|
-
ref={props.ref}
|
|
79
|
-
modules={createModules(`session:${props.session_id}`)}
|
|
80
|
-
/>
|
|
81
|
-
)
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function readAutoUpdate(options: unknown) {
|
|
88
|
-
if (!options || typeof options !== "object") return true
|
|
89
|
-
const value = (options as { autoUpdate?: unknown }).autoUpdate
|
|
90
|
-
return typeof value === "boolean" ? value : true
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function hasSnippetsPlugin(api: TuiPluginApi) {
|
|
94
|
-
return configHasSnippets(api.state.config.plugin) || configHasSnippets(api.tuiConfig.plugin) || api.plugins.list().some(pluginHasSnippets)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function configHasSnippets(plugins: unknown) {
|
|
98
|
-
if (!Array.isArray(plugins)) return false
|
|
99
|
-
return plugins.some((plugin) => specHasSnippets(Array.isArray(plugin) ? plugin[0] : plugin))
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function pluginHasSnippets(plugin: ReturnType<TuiPluginApi["plugins"]["list"]>[number]) {
|
|
103
|
-
return plugin.enabled && (specHasSnippets(plugin.id) || specHasSnippets(plugin.spec))
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function specHasSnippets(spec: unknown) {
|
|
107
|
-
if (typeof spec !== "string") return false
|
|
108
|
-
return spec === "opencode-snippets" || spec.startsWith("opencode-snippets@") || spec.includes("/opencode-snippets")
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const plugin: TuiPluginModule & { id: string } = {
|
|
112
|
-
id: "local.opencode-vim",
|
|
113
|
-
tui,
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export default plugin
|
package/src/prompt/host.tsx
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import type { TuiPromptRef } from "@opencode-ai/plugin/tui"
|
|
3
|
-
import type { JSX } from "@opentui/solid"
|
|
4
|
-
import type { PromptContext, PromptModule } from "./types"
|
|
5
|
-
import { submitModules } from "./modules"
|
|
6
|
-
|
|
7
|
-
type HostPromptProps = {
|
|
8
|
-
ctx: PromptContext
|
|
9
|
-
modules: PromptModule[]
|
|
10
|
-
ref?: (ref: TuiPromptRef | undefined) => void
|
|
11
|
-
right?: JSX.Element
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function HostPrompt(props: HostPromptProps) {
|
|
15
|
-
const api = props.ctx.api
|
|
16
|
-
|
|
17
|
-
const setRef = (ref: TuiPromptRef | undefined) => {
|
|
18
|
-
props.ctx.setPromptRef(ref)
|
|
19
|
-
props.ref?.(ref)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const onSubmit = () => {
|
|
23
|
-
if (submitModules(props.modules, props.ctx)) return
|
|
24
|
-
props.ctx.submitHost()
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<api.ui.Prompt
|
|
29
|
-
sessionID={props.ctx.sessionID}
|
|
30
|
-
workspaceID={props.ctx.workspaceID}
|
|
31
|
-
visible={props.ctx.visible}
|
|
32
|
-
disabled={props.ctx.disabled}
|
|
33
|
-
onSubmit={onSubmit}
|
|
34
|
-
ref={setRef}
|
|
35
|
-
right={props.right}
|
|
36
|
-
/>
|
|
37
|
-
)
|
|
38
|
-
}
|
package/src/prompt/modules.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { PromptContext, PromptModule } from "./types"
|
|
2
|
-
|
|
3
|
-
export function sortModules(modules: PromptModule[]) {
|
|
4
|
-
return [...modules].sort((left, right) => (left.order ?? 0) - (right.order ?? 0) || left.id.localeCompare(right.id))
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export function submitModules(modules: PromptModule[], ctx: PromptContext) {
|
|
8
|
-
for (const module of modules) {
|
|
9
|
-
if (module.onSubmit?.(ctx) === true) return true
|
|
10
|
-
}
|
|
11
|
-
return false
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function notifyPromptRef(modules: PromptModule[], ref: ReturnType<PromptContext["prompt"]>, ctx: PromptContext) {
|
|
15
|
-
for (const module of modules) module.onPromptRef?.(ref, ctx)
|
|
16
|
-
}
|
package/src/prompt/root.tsx
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import { onCleanup } from "solid-js"
|
|
3
|
-
import type { JSX } from "@opentui/solid"
|
|
4
|
-
import { HostPrompt } from "./host"
|
|
5
|
-
import { notifyPromptRef, sortModules } from "./modules"
|
|
6
|
-
import type { PromptContext, PromptRootProps } from "./types"
|
|
7
|
-
|
|
8
|
-
export function PromptRoot(props: PromptRootProps) {
|
|
9
|
-
const modules = sortModules(props.modules)
|
|
10
|
-
const api = props.api
|
|
11
|
-
let current: ReturnType<PromptContext["prompt"]>
|
|
12
|
-
const ctx: PromptContext = {
|
|
13
|
-
api,
|
|
14
|
-
slot: props.slot,
|
|
15
|
-
kind: props.kind,
|
|
16
|
-
get sessionID() {
|
|
17
|
-
return props.sessionID
|
|
18
|
-
},
|
|
19
|
-
get workspaceID() {
|
|
20
|
-
return props.workspaceID
|
|
21
|
-
},
|
|
22
|
-
get visible() {
|
|
23
|
-
return props.visible
|
|
24
|
-
},
|
|
25
|
-
get disabled() {
|
|
26
|
-
return props.disabled
|
|
27
|
-
},
|
|
28
|
-
prompt: () => current,
|
|
29
|
-
setPromptRef(ref) {
|
|
30
|
-
current = ref
|
|
31
|
-
notifyPromptRef(modules, ref, ctx)
|
|
32
|
-
},
|
|
33
|
-
submitHost: () => props.onSubmit?.(),
|
|
34
|
-
requestRender: () => api.renderer.requestRender(),
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const cleanups = modules.flatMap((module) => {
|
|
38
|
-
const cleanup = module.setup?.(ctx)
|
|
39
|
-
return typeof cleanup === "function" ? [cleanup] : []
|
|
40
|
-
})
|
|
41
|
-
onCleanup(() => {
|
|
42
|
-
for (const cleanup of cleanups) cleanup()
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
const moduleAbove = modules.map((module) => module.renderAbove?.(ctx)).filter(Boolean) as JSX.Element[]
|
|
46
|
-
const moduleBelow = modules.map((module) => module.renderBelow?.(ctx)).filter(Boolean) as JSX.Element[]
|
|
47
|
-
const moduleRight = modules.map((module) => module.renderRight?.(ctx)).filter(Boolean) as JSX.Element[]
|
|
48
|
-
const hostRight = renderHostRight(props)
|
|
49
|
-
|
|
50
|
-
return (
|
|
51
|
-
<box>
|
|
52
|
-
{moduleAbove}
|
|
53
|
-
<HostPrompt ctx={ctx} modules={modules} ref={props.ref} right={<RightItems items={[...moduleRight, hostRight]} />} />
|
|
54
|
-
{moduleBelow}
|
|
55
|
-
</box>
|
|
56
|
-
)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function renderHostRight(props: PromptRootProps) {
|
|
60
|
-
const Slot = props.api.ui.Slot
|
|
61
|
-
if (props.kind === "home") return <Slot name="home_prompt_right" workspace_id={props.workspaceID} />
|
|
62
|
-
return <Slot name="session_prompt_right" session_id={props.sessionID ?? ""} />
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function RightItems(props: { items: JSX.Element[] }) {
|
|
66
|
-
const items = props.items.filter(Boolean)
|
|
67
|
-
if (items.length === 0) return undefined
|
|
68
|
-
return <box flexDirection="row">{items}</box>
|
|
69
|
-
}
|
package/src/prompt/types.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import type { JSX } from "@opentui/solid"
|
|
2
|
-
import type { TuiPluginApi, TuiPromptRef, TuiSlotContext } from "@opencode-ai/plugin/tui"
|
|
3
|
-
|
|
4
|
-
export type PromptKind = "home" | "session"
|
|
5
|
-
|
|
6
|
-
export type PromptContext = {
|
|
7
|
-
api: TuiPluginApi
|
|
8
|
-
slot: TuiSlotContext
|
|
9
|
-
kind: PromptKind
|
|
10
|
-
sessionID?: string
|
|
11
|
-
workspaceID?: string
|
|
12
|
-
visible?: boolean
|
|
13
|
-
disabled?: boolean
|
|
14
|
-
prompt: () => TuiPromptRef | undefined
|
|
15
|
-
setPromptRef: (ref: TuiPromptRef | undefined) => void
|
|
16
|
-
submitHost: () => void
|
|
17
|
-
requestRender: () => void
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type PromptModule = {
|
|
21
|
-
id: string
|
|
22
|
-
order?: number
|
|
23
|
-
setup?: (ctx: PromptContext) => void | (() => void)
|
|
24
|
-
onPromptRef?: (ref: TuiPromptRef | undefined, ctx: PromptContext) => void
|
|
25
|
-
onSubmit?: (ctx: PromptContext) => boolean | void
|
|
26
|
-
renderAbove?: (ctx: PromptContext) => JSX.Element
|
|
27
|
-
renderBelow?: (ctx: PromptContext) => JSX.Element
|
|
28
|
-
renderRight?: (ctx: PromptContext) => JSX.Element
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export type PromptRootProps = {
|
|
32
|
-
api: TuiPluginApi
|
|
33
|
-
slot: TuiSlotContext
|
|
34
|
-
kind: PromptKind
|
|
35
|
-
sessionID?: string
|
|
36
|
-
workspaceID?: string
|
|
37
|
-
visible?: boolean
|
|
38
|
-
disabled?: boolean
|
|
39
|
-
onSubmit?: () => void
|
|
40
|
-
ref?: (ref: TuiPromptRef | undefined) => void
|
|
41
|
-
modules: PromptModule[]
|
|
42
|
-
}
|
package/src/update.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { rm } from "node:fs/promises"
|
|
2
|
-
import { basename, dirname, join } from "node:path"
|
|
3
|
-
import { fileURLToPath } from "node:url"
|
|
4
|
-
import type { TuiPluginMeta } from "@opencode-ai/plugin/tui"
|
|
5
|
-
|
|
6
|
-
type PackageJson = {
|
|
7
|
-
name?: string
|
|
8
|
-
version?: string
|
|
9
|
-
dependencies?: Record<string, string>
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
type UpdateResult =
|
|
13
|
-
| { updated: true; name: string; current: string; latest: string }
|
|
14
|
-
| { updated: false; error: "remove_failed"; name: string; current: string; latest: string }
|
|
15
|
-
| { updated: false }
|
|
16
|
-
|
|
17
|
-
export async function checkAutoUpdate(meta: TuiPluginMeta, signal: AbortSignal): Promise<UpdateResult> {
|
|
18
|
-
if (meta.source !== "npm") return { updated: false }
|
|
19
|
-
|
|
20
|
-
const packageDir = await findPackageDir()
|
|
21
|
-
if (!packageDir) return { updated: false }
|
|
22
|
-
|
|
23
|
-
const pkg = await readPackageJson(join(packageDir, "package.json"))
|
|
24
|
-
if (!pkg.name || !pkg.version) return { updated: false }
|
|
25
|
-
|
|
26
|
-
const latest = await fetchLatestVersion(pkg.name, signal)
|
|
27
|
-
if (!latest || !isVersionNewer(latest, pkg.version)) return { updated: false }
|
|
28
|
-
|
|
29
|
-
const removeDir = await updateRemoveDir(packageDir, pkg.name)
|
|
30
|
-
try {
|
|
31
|
-
await rm(removeDir, { recursive: true, force: true })
|
|
32
|
-
} catch {
|
|
33
|
-
return { updated: false, error: "remove_failed", name: pkg.name, current: pkg.version, latest }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return { updated: true, name: pkg.name, current: pkg.version, latest }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function findPackageDir() {
|
|
40
|
-
let dir = dirname(fileURLToPath(import.meta.url))
|
|
41
|
-
for (;;) {
|
|
42
|
-
const packagePath = join(dir, "package.json")
|
|
43
|
-
const pkg = await readPackageJson(packagePath)
|
|
44
|
-
if (pkg?.name === "opencode-vim") return dir
|
|
45
|
-
|
|
46
|
-
const parent = dirname(dir)
|
|
47
|
-
if (parent === dir) return undefined
|
|
48
|
-
dir = parent
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function updateRemoveDir(packageDir: string, name: string) {
|
|
53
|
-
const nodeModulesDir = dirname(packageDir)
|
|
54
|
-
if (basename(nodeModulesDir) !== "node_modules") return packageDir
|
|
55
|
-
|
|
56
|
-
const wrapperDir = dirname(nodeModulesDir)
|
|
57
|
-
const wrapperPkg = await readPackageJson(join(wrapperDir, "package.json"))
|
|
58
|
-
return wrapperPkg?.dependencies?.[name] ? wrapperDir : packageDir
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function readPackageJson(path: string): Promise<PackageJson | undefined> {
|
|
62
|
-
try {
|
|
63
|
-
const file = Bun.file(path)
|
|
64
|
-
if (!(await file.exists())) return undefined
|
|
65
|
-
const data = await file.json()
|
|
66
|
-
return data && typeof data === "object" ? (data as PackageJson) : undefined
|
|
67
|
-
} catch {
|
|
68
|
-
return undefined
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function fetchLatestVersion(name: string, signal: AbortSignal) {
|
|
73
|
-
try {
|
|
74
|
-
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, { signal })
|
|
75
|
-
if (!response.ok) return undefined
|
|
76
|
-
const data = await response.json()
|
|
77
|
-
return data && typeof data === "object" && typeof data.version === "string" ? data.version : undefined
|
|
78
|
-
} catch {
|
|
79
|
-
return undefined
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function isVersionNewer(latest: string, current: string) {
|
|
84
|
-
const next = parseVersion(latest)
|
|
85
|
-
const prev = parseVersion(current)
|
|
86
|
-
if (!next || !prev) return false
|
|
87
|
-
|
|
88
|
-
for (let i = 0; i < 3; i++) {
|
|
89
|
-
if (next.parts[i] !== prev.parts[i]) return next.parts[i] > prev.parts[i]
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (!next.pre.length && prev.pre.length) return true
|
|
93
|
-
if (next.pre.length && !prev.pre.length) return false
|
|
94
|
-
|
|
95
|
-
for (let i = 0; i < Math.max(next.pre.length, prev.pre.length); i++) {
|
|
96
|
-
const a = next.pre[i]
|
|
97
|
-
const b = prev.pre[i]
|
|
98
|
-
if (a === undefined) return false
|
|
99
|
-
if (b === undefined) return true
|
|
100
|
-
if (a === b) continue
|
|
101
|
-
|
|
102
|
-
const aNumber = /^\d+$/.test(a) ? Number(a) : undefined
|
|
103
|
-
const bNumber = /^\d+$/.test(b) ? Number(b) : undefined
|
|
104
|
-
if (aNumber !== undefined && bNumber !== undefined) return aNumber > bNumber
|
|
105
|
-
if (aNumber !== undefined) return false
|
|
106
|
-
if (bNumber !== undefined) return true
|
|
107
|
-
return a > b
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return false
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function parseVersion(version: string) {
|
|
114
|
-
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.+)?$/)
|
|
115
|
-
if (!match) return undefined
|
|
116
|
-
return {
|
|
117
|
-
parts: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
118
|
-
pre: match[4]?.split(".") ?? [],
|
|
119
|
-
}
|
|
120
|
-
}
|
package/tui.tsx
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import { Plugin } from "@opencode/plugin/tui"
|
|
3
|
-
import { InputRenderable, type CursorStyleOptions, type KeyEvent } from "@opentui/core"
|
|
4
|
-
import { createEffect, onCleanup } from "solid-js"
|
|
5
|
-
import { applyVimCursorStyle, focusedInput } from "./src/modules/vim/actions"
|
|
6
|
-
import { createVimConfig } from "./src/modules/vim/config"
|
|
7
|
-
import { editInput } from "./src/modules/vim/edit"
|
|
8
|
-
import { keyNotation } from "./src/modules/vim/keys"
|
|
9
|
-
import { createVimLog } from "./src/modules/vim/log"
|
|
10
|
-
import { displayToChar } from "./src/modules/vim/map"
|
|
11
|
-
import { createVimState } from "./src/modules/vim/state"
|
|
12
|
-
import { createVimeeAdapter } from "./src/modules/vim/vimee"
|
|
13
|
-
import { VimStatus } from "./view"
|
|
14
|
-
|
|
15
|
-
type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
|
|
16
|
-
|
|
17
|
-
export default Plugin.define({
|
|
18
|
-
id: "opencode-vim",
|
|
19
|
-
setup(context) {
|
|
20
|
-
context.ui.slot({ append: "app", render: () => <VimHost context={context} /> })
|
|
21
|
-
},
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
function VimHost(props: { context: Context }) {
|
|
25
|
-
const config = createVimConfig(props.context.options)
|
|
26
|
-
const normalMappings = Object.keys(config.keymaps.normal ?? {})
|
|
27
|
-
const log = createVimLog(config)
|
|
28
|
-
const state = createVimState(config.defaultMode, log)
|
|
29
|
-
const vimee = createVimeeAdapter(state, config, log)
|
|
30
|
-
const dialogState = createVimState(config.defaultMode, log)
|
|
31
|
-
const dialogVimee = createVimeeAdapter(dialogState, config, log)
|
|
32
|
-
const promptVim = { state, vimee }
|
|
33
|
-
const dialogVim = { state: dialogState, vimee: dialogVimee }
|
|
34
|
-
let dialogInput: typeof props.context.renderer.currentFocusedEditor = null
|
|
35
|
-
const [saved, setSaved] = props.context.storage.store("state", { initial: { enabled: true } })
|
|
36
|
-
const enabled = () => saved.enabled
|
|
37
|
-
const ctx = createCompatContext(props.context)
|
|
38
|
-
let cursorMode = ""
|
|
39
|
-
let cursorInput: typeof props.context.renderer.currentFocusedEditor = null
|
|
40
|
-
let originalCursorStyle: CursorStyleOptions | undefined
|
|
41
|
-
|
|
42
|
-
const removeStatus = props.context.ui.slot({
|
|
43
|
-
prepend: "prompt.footer",
|
|
44
|
-
render: (footer) =>
|
|
45
|
-
footer.mode === "normal" ? (
|
|
46
|
-
<VimStatus
|
|
47
|
-
mode={state.mode}
|
|
48
|
-
enabled={enabled}
|
|
49
|
-
theme={compatTheme(props.context)}
|
|
50
|
-
/>
|
|
51
|
-
) : null,
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
props.context.keymap.layer(() => ({
|
|
55
|
-
mode: "global",
|
|
56
|
-
commands: [
|
|
57
|
-
{
|
|
58
|
-
id: "opencode-vim.toggle",
|
|
59
|
-
title: "Toggle Vim Mode",
|
|
60
|
-
description: "Enable or disable Vim key handling",
|
|
61
|
-
group: "Vim",
|
|
62
|
-
palette: true,
|
|
63
|
-
slash: { name: "vim" },
|
|
64
|
-
run() {
|
|
65
|
-
const next = !enabled()
|
|
66
|
-
void setSaved((draft) => {
|
|
67
|
-
draft.enabled = next
|
|
68
|
-
})
|
|
69
|
-
if (!next) {
|
|
70
|
-
vimee.suspend()
|
|
71
|
-
dialogVimee.suspend()
|
|
72
|
-
}
|
|
73
|
-
props.context.ui.toast.show({ message: `Vim mode ${next ? "enabled" : "disabled"}`, variant: "info" })
|
|
74
|
-
props.context.renderer.requestRender()
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
],
|
|
78
|
-
}))
|
|
79
|
-
|
|
80
|
-
const onKey = (event: KeyEvent) => {
|
|
81
|
-
if (!enabled() || event.defaultPrevented) return
|
|
82
|
-
const kind = inputKind(props.context)
|
|
83
|
-
if (!kind) return
|
|
84
|
-
if (props.context.keymap.pending().length) return
|
|
85
|
-
const { state, vimee } = kind === "dialog" ? dialogVim : promptVim
|
|
86
|
-
const key = keyNotation(event as never)
|
|
87
|
-
if (!key) return
|
|
88
|
-
const mapped = normalMappings.some((sequence) => sequence.startsWith(key))
|
|
89
|
-
if (passThroughKey(event, key, state.mode(), vimee.isPending(), mapped)) return
|
|
90
|
-
if (key === "<Esc>" && state.mode() === "normal" && !vimee.isPending()) return
|
|
91
|
-
|
|
92
|
-
if (kind === "dialog" && !vimee.isPending() && !mapped && state.mode() === "normal") {
|
|
93
|
-
if (key === "<Tab>" || key === "<Home>" || key === "<End>" || key === "<PageUp>" || key === "<PageDown>") return
|
|
94
|
-
const command = key === "j" ? "dialog.select.next" : key === "k" ? "dialog.select.prev" : undefined
|
|
95
|
-
if (command && props.context.keymap.commands().some((item) => item.id === command)) {
|
|
96
|
-
event.preventDefault()
|
|
97
|
-
event.stopPropagation()
|
|
98
|
-
props.context.keymap.dispatch(command)
|
|
99
|
-
return
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (kind === "prompt" && !vimee.isPending() && sendCompletionKey(event, props.context, key, state.mode())) {
|
|
104
|
-
syncCursor(true)
|
|
105
|
-
return
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const before = state.mode()
|
|
109
|
-
let consumed = false
|
|
110
|
-
try {
|
|
111
|
-
consumed = vimee.handle(event as never, key, ctx as never)
|
|
112
|
-
} finally {
|
|
113
|
-
if (consumed || state.mode() !== before) {
|
|
114
|
-
event.preventDefault()
|
|
115
|
-
event.stopPropagation()
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (consumed) syncCursor(true)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const onFocus = () => {
|
|
122
|
-
vimee.suspend()
|
|
123
|
-
dialogVimee.suspend()
|
|
124
|
-
syncCursor()
|
|
125
|
-
}
|
|
126
|
-
props.context.renderer.keyInput.prependListener("keypress", onKey)
|
|
127
|
-
props.context.renderer.on("focused_editor", onFocus)
|
|
128
|
-
createEffect(() => syncCursor())
|
|
129
|
-
onCleanup(() => {
|
|
130
|
-
removeStatus()
|
|
131
|
-
props.context.renderer.keyInput.off("keypress", onKey)
|
|
132
|
-
props.context.renderer.off("focused_editor", onFocus)
|
|
133
|
-
vimee.cleanup()
|
|
134
|
-
dialogVimee.cleanup()
|
|
135
|
-
restoreCursor()
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
return null
|
|
139
|
-
|
|
140
|
-
function syncCursor(force = false) {
|
|
141
|
-
const input = props.context.renderer.currentFocusedEditor
|
|
142
|
-
const kind = inputKind(props.context)
|
|
143
|
-
if (!enabled() || !kind || !input) {
|
|
144
|
-
restoreCursor()
|
|
145
|
-
return
|
|
146
|
-
}
|
|
147
|
-
const { state, vimee } = kind === "dialog" ? dialogVim : promptVim
|
|
148
|
-
if (kind === "dialog" && dialogInput !== input) {
|
|
149
|
-
dialogInput = input
|
|
150
|
-
dialogVimee.suspend()
|
|
151
|
-
dialogState.setMode(config.defaultMode)
|
|
152
|
-
}
|
|
153
|
-
const mode = state.mode()
|
|
154
|
-
vimee.attach(ctx)
|
|
155
|
-
const inputChanged = cursorInput !== input
|
|
156
|
-
if (inputChanged) {
|
|
157
|
-
restoreCursor()
|
|
158
|
-
cursorInput = input
|
|
159
|
-
originalCursorStyle = input.cursorStyle
|
|
160
|
-
}
|
|
161
|
-
if (!force && !inputChanged && cursorMode === mode) return
|
|
162
|
-
if (applyVimCursorStyle(ctx as never, config.cursorStyles[mode])) {
|
|
163
|
-
cursorMode = mode
|
|
164
|
-
props.context.renderer.requestRender()
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function restoreCursor() {
|
|
169
|
-
if (cursorInput && !cursorInput.isDestroyed && originalCursorStyle) cursorInput.cursorStyle = originalCursorStyle
|
|
170
|
-
cursorInput = null
|
|
171
|
-
cursorMode = ""
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function createCompatContext(context: Context) {
|
|
176
|
-
const prompt = {
|
|
177
|
-
get current() {
|
|
178
|
-
return { input: focusedInputValue(context), mode: "normal", parts: [] }
|
|
179
|
-
},
|
|
180
|
-
get focused() {
|
|
181
|
-
return inputKind(context) !== undefined
|
|
182
|
-
},
|
|
183
|
-
set(value: { input: string }) {
|
|
184
|
-
const input = context.renderer.currentFocusedEditor
|
|
185
|
-
if (input instanceof InputRenderable) input.value = value.input
|
|
186
|
-
else if (input) editInput(input, value.input)
|
|
187
|
-
},
|
|
188
|
-
submit() {
|
|
189
|
-
if (inputKind(context) === "dialog") {
|
|
190
|
-
const command = context.keymap.commands().some((item) => item.id === "dialog.select.submit")
|
|
191
|
-
? "dialog.select.submit"
|
|
192
|
-
: "dialog.prompt.submit"
|
|
193
|
-
context.keymap.dispatch(command)
|
|
194
|
-
} else {
|
|
195
|
-
context.keymap.dispatch("prompt.submit")
|
|
196
|
-
}
|
|
197
|
-
},
|
|
198
|
-
blur() {
|
|
199
|
-
context.renderer.currentFocusedEditor?.blur()
|
|
200
|
-
},
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
api: {
|
|
205
|
-
renderer: context.renderer,
|
|
206
|
-
keymap: {
|
|
207
|
-
dispatchCommand(command: string) {
|
|
208
|
-
const ok = context.keymap.commands().some((item) => item.id === command)
|
|
209
|
-
if (ok) context.keymap.dispatch(command)
|
|
210
|
-
return { ok }
|
|
211
|
-
},
|
|
212
|
-
},
|
|
213
|
-
theme: { current: compatTheme(context) },
|
|
214
|
-
},
|
|
215
|
-
kind: context.ui.router.current().type === "session" ? "session" : "home",
|
|
216
|
-
prompt: () => (inputKind(context) ? prompt : undefined),
|
|
217
|
-
requestRender: () => context.renderer.requestRender(),
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function inputKind(context: Context): "prompt" | "dialog" | undefined {
|
|
222
|
-
if (!context.renderer.currentFocusedEditor) return
|
|
223
|
-
const commands = context.keymap.commands()
|
|
224
|
-
if (context.keymap.mode.current() === "modal") {
|
|
225
|
-
if (commands.some((item) => item.id === "dialog.select.submit" || item.id === "dialog.prompt.submit")) return "dialog"
|
|
226
|
-
return
|
|
227
|
-
}
|
|
228
|
-
if (commands.some(
|
|
229
|
-
(item) =>
|
|
230
|
-
item.id === "prompt.submit" || item.id === "prompt.autocomplete.next" || item.id === "prompt.history.previous",
|
|
231
|
-
)) return "prompt"
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function focusedInputValue(context: Context) {
|
|
235
|
-
return context.renderer.currentFocusedEditor?.plainText ?? ""
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function compatTheme(context: Context) {
|
|
239
|
-
return {
|
|
240
|
-
get background() { return context.theme.background.base },
|
|
241
|
-
get info() { return context.theme.text.feedback.info.base },
|
|
242
|
-
get success() { return context.theme.text.feedback.success.base },
|
|
243
|
-
get warning() { return context.theme.text.feedback.warning.base },
|
|
244
|
-
get textMuted() { return context.theme.text.muted },
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function passThroughKey(event: KeyEvent, key: string, mode: string, pending: boolean, mapped: boolean) {
|
|
249
|
-
if (mode !== "normal") return false
|
|
250
|
-
if (mapped) return false
|
|
251
|
-
if (event.ctrl && key !== "<C-r>" && key !== "<C-[>") return true
|
|
252
|
-
return event.super === true || event.meta === true || (isArrowKey(key) && !pending)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function sendCompletionKey(event: KeyEvent, context: Context, key: string, mode: string) {
|
|
256
|
-
if (mode !== "normal") return false
|
|
257
|
-
const command = key === "j" ? "prompt.autocomplete.next" : key === "k" ? "prompt.autocomplete.prev" : undefined
|
|
258
|
-
if (!command || !isCompletionToken(context) || !context.keymap.commands().some((item) => item.id === command)) return false
|
|
259
|
-
event.preventDefault()
|
|
260
|
-
event.stopPropagation()
|
|
261
|
-
context.keymap.dispatch(command)
|
|
262
|
-
return true
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function isCompletionToken(context: Context) {
|
|
266
|
-
const input = context.renderer.currentFocusedEditor
|
|
267
|
-
const text = input?.plainText ?? ""
|
|
268
|
-
const index = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0))
|
|
269
|
-
const before = text.slice(0, Math.min(index + 1, text.length))
|
|
270
|
-
return /^\/\S*$/.test(before) || /(?:^|\s)@\S*$/.test(before)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function isArrowKey(key: string) {
|
|
274
|
-
return key === "<Left>" || key === "<Down>" || key === "<Up>" || key === "<Right>"
|
|
275
|
-
}
|