opencode-vim 0.0.20 → 0.0.22

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 +1,275 @@
1
- export { default } from "./src/plugin"
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
+ }
package/view.tsx ADDED
@@ -0,0 +1,29 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TextRenderable } from "@opentui/core"
3
+ import type { Accessor } from "solid-js"
4
+ import type { VimMode } from "./src/modules/vim/state"
5
+
6
+ type VimStatusProps = {
7
+ mode: Accessor<VimMode>
8
+ enabled: Accessor<boolean>
9
+ theme: {
10
+ success: TextRenderable["fg"]
11
+ warning: TextRenderable["fg"]
12
+ }
13
+ }
14
+
15
+ export function VimStatus(props: VimStatusProps) {
16
+ return (
17
+ <box paddingRight={1} flexDirection="row">
18
+ <text fg={props.mode() === "insert" ? props.theme.success : props.theme.warning}>
19
+ {props.enabled() ? modeLabel(props.mode()) : ""}
20
+ </text>
21
+ </box>
22
+ )
23
+ }
24
+
25
+ function modeLabel(mode: VimMode) {
26
+ if (mode === "visual") return "VISUAL"
27
+ if (mode === "visual-line") return "VISUAL LINE"
28
+ return mode === "normal" ? "NORMAL" : "INSERT"
29
+ }
package/tui.json.example DELETED
@@ -1,37 +0,0 @@
1
- {
2
- "$schema": "https://opencode.ai/tui.json",
3
- "plugin": [
4
- [
5
- "./plugin/opencode-vim",
6
- {
7
- "vim": {
8
- "defaultMode": "insert",
9
- "keymapTimeout": 500,
10
- "pendingDisplayDelay": 120,
11
- "cursorStyles": {
12
- "insert": {
13
- "style": "line",
14
- "blinking": true
15
- },
16
- "normal": {
17
- "style": "block",
18
- "blinking": true
19
- }
20
- },
21
- "debug": false,
22
- "debugPath": "/home/you/.cache/opencode/opencode-vim.log",
23
- "keymaps": {
24
- "insert": {
25
- "kj": "normal"
26
- },
27
- "normal": {
28
- "i": "insert",
29
- "<CR>": "submit",
30
- "Y": "y$"
31
- }
32
- }
33
- }
34
- }
35
- ]
36
- ]
37
- }