opencode-vim 0.0.20 → 0.0.21

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,281 @@
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
+ pending={() => readablePending(state.pending())}
49
+ enabled={enabled}
50
+ theme={compatTheme(props.context)}
51
+ pendingDisplayDelay={config.pendingDisplayDelay}
52
+ />
53
+ ) : null,
54
+ })
55
+
56
+ props.context.keymap.layer(() => ({
57
+ mode: "global",
58
+ commands: [
59
+ {
60
+ id: "opencode-vim.toggle",
61
+ title: "Toggle Vim Mode",
62
+ description: "Enable or disable Vim key handling",
63
+ group: "Vim",
64
+ palette: true,
65
+ slash: { name: "vim" },
66
+ run() {
67
+ const next = !enabled()
68
+ void setSaved((draft) => {
69
+ draft.enabled = next
70
+ })
71
+ if (!next) {
72
+ vimee.suspend()
73
+ dialogVimee.suspend()
74
+ }
75
+ props.context.ui.toast.show({ message: `Vim mode ${next ? "enabled" : "disabled"}`, variant: "info" })
76
+ props.context.renderer.requestRender()
77
+ },
78
+ },
79
+ ],
80
+ }))
81
+
82
+ const onKey = (event: KeyEvent) => {
83
+ if (!enabled() || event.defaultPrevented) return
84
+ const kind = inputKind(props.context)
85
+ if (!kind) return
86
+ if (props.context.keymap.pending().length) return
87
+ const { state, vimee } = kind === "dialog" ? dialogVim : promptVim
88
+ const key = keyNotation(event as never)
89
+ if (!key) return
90
+ const mapped = normalMappings.some((sequence) => sequence.startsWith(key))
91
+ if (passThroughKey(event, key, state.mode(), vimee.isPending(), mapped)) return
92
+ if (key === "<Esc>" && state.mode() === "normal" && !vimee.isPending()) return
93
+
94
+ if (kind === "dialog" && !vimee.isPending() && !mapped && state.mode() === "normal") {
95
+ if (key === "<Tab>" || key === "<Home>" || key === "<End>" || key === "<PageUp>" || key === "<PageDown>") return
96
+ const command = key === "j" ? "dialog.select.next" : key === "k" ? "dialog.select.prev" : undefined
97
+ if (command && props.context.keymap.commands().some((item) => item.id === command)) {
98
+ event.preventDefault()
99
+ event.stopPropagation()
100
+ props.context.keymap.dispatch(command)
101
+ return
102
+ }
103
+ }
104
+
105
+ if (kind === "prompt" && !vimee.isPending() && sendCompletionKey(event, props.context, key, state.mode())) {
106
+ syncCursor(true)
107
+ return
108
+ }
109
+
110
+ const before = state.mode()
111
+ let consumed = false
112
+ try {
113
+ consumed = vimee.handle(event as never, key, ctx as never)
114
+ } finally {
115
+ if (consumed || state.mode() !== before) {
116
+ event.preventDefault()
117
+ event.stopPropagation()
118
+ }
119
+ }
120
+ if (consumed) syncCursor(true)
121
+ }
122
+
123
+ const onFocus = () => {
124
+ vimee.suspend()
125
+ dialogVimee.suspend()
126
+ syncCursor()
127
+ }
128
+ props.context.renderer.keyInput.prependListener("keypress", onKey)
129
+ props.context.renderer.on("focused_editor", onFocus)
130
+ createEffect(() => syncCursor())
131
+ onCleanup(() => {
132
+ removeStatus()
133
+ props.context.renderer.keyInput.off("keypress", onKey)
134
+ props.context.renderer.off("focused_editor", onFocus)
135
+ vimee.cleanup()
136
+ dialogVimee.cleanup()
137
+ restoreCursor()
138
+ })
139
+
140
+ return null
141
+
142
+ function syncCursor(force = false) {
143
+ const input = props.context.renderer.currentFocusedEditor
144
+ const kind = inputKind(props.context)
145
+ if (!enabled() || !kind || !input) {
146
+ restoreCursor()
147
+ return
148
+ }
149
+ const { state, vimee } = kind === "dialog" ? dialogVim : promptVim
150
+ if (kind === "dialog" && dialogInput !== input) {
151
+ dialogInput = input
152
+ dialogVimee.suspend()
153
+ dialogState.setMode(config.defaultMode)
154
+ }
155
+ const mode = state.mode()
156
+ vimee.attach(ctx)
157
+ const inputChanged = cursorInput !== input
158
+ if (inputChanged) {
159
+ restoreCursor()
160
+ cursorInput = input
161
+ originalCursorStyle = input.cursorStyle
162
+ }
163
+ if (!force && !inputChanged && cursorMode === mode) return
164
+ if (applyVimCursorStyle(ctx as never, config.cursorStyles[mode])) {
165
+ cursorMode = mode
166
+ props.context.renderer.requestRender()
167
+ }
168
+ }
169
+
170
+ function restoreCursor() {
171
+ if (cursorInput && !cursorInput.isDestroyed && originalCursorStyle) cursorInput.cursorStyle = originalCursorStyle
172
+ cursorInput = null
173
+ cursorMode = ""
174
+ }
175
+ }
176
+
177
+ function createCompatContext(context: Context) {
178
+ const prompt = {
179
+ get current() {
180
+ return { input: focusedInputValue(context), mode: "normal", parts: [] }
181
+ },
182
+ get focused() {
183
+ return inputKind(context) !== undefined
184
+ },
185
+ set(value: { input: string }) {
186
+ const input = context.renderer.currentFocusedEditor
187
+ if (input instanceof InputRenderable) input.value = value.input
188
+ else if (input) editInput(input, value.input)
189
+ },
190
+ submit() {
191
+ if (inputKind(context) === "dialog") {
192
+ const command = context.keymap.commands().some((item) => item.id === "dialog.select.submit")
193
+ ? "dialog.select.submit"
194
+ : "dialog.prompt.submit"
195
+ context.keymap.dispatch(command)
196
+ } else {
197
+ context.keymap.dispatch("prompt.submit")
198
+ }
199
+ },
200
+ blur() {
201
+ context.renderer.currentFocusedEditor?.blur()
202
+ },
203
+ }
204
+
205
+ return {
206
+ api: {
207
+ renderer: context.renderer,
208
+ keymap: {
209
+ dispatchCommand(command: string) {
210
+ const ok = context.keymap.commands().some((item) => item.id === command)
211
+ if (ok) context.keymap.dispatch(command)
212
+ return { ok }
213
+ },
214
+ },
215
+ theme: { current: compatTheme(context) },
216
+ },
217
+ kind: context.ui.router.current().type === "session" ? "session" : "home",
218
+ prompt: () => (inputKind(context) ? prompt : undefined),
219
+ requestRender: () => context.renderer.requestRender(),
220
+ }
221
+ }
222
+
223
+ function inputKind(context: Context): "prompt" | "dialog" | undefined {
224
+ if (!context.renderer.currentFocusedEditor) return
225
+ const commands = context.keymap.commands()
226
+ if (context.keymap.mode.current() === "modal") {
227
+ if (commands.some((item) => item.id === "dialog.select.submit" || item.id === "dialog.prompt.submit")) return "dialog"
228
+ return
229
+ }
230
+ if (commands.some(
231
+ (item) =>
232
+ item.id === "prompt.submit" || item.id === "prompt.autocomplete.next" || item.id === "prompt.history.previous",
233
+ )) return "prompt"
234
+ }
235
+
236
+ function focusedInputValue(context: Context) {
237
+ return context.renderer.currentFocusedEditor?.plainText ?? ""
238
+ }
239
+
240
+ function compatTheme(context: Context) {
241
+ return {
242
+ get background() { return context.theme.background.base },
243
+ get info() { return context.theme.text.feedback.info.base },
244
+ get success() { return context.theme.text.feedback.success.base },
245
+ get warning() { return context.theme.text.feedback.warning.base },
246
+ get textMuted() { return context.theme.text.muted },
247
+ }
248
+ }
249
+
250
+ function passThroughKey(event: KeyEvent, key: string, mode: string, pending: boolean, mapped: boolean) {
251
+ if (mode !== "normal") return false
252
+ if (mapped) return false
253
+ if (event.ctrl && key !== "<C-r>" && key !== "<C-[>") return true
254
+ return event.super === true || event.meta === true || (isArrowKey(key) && !pending)
255
+ }
256
+
257
+ function sendCompletionKey(event: KeyEvent, context: Context, key: string, mode: string) {
258
+ if (mode !== "normal") return false
259
+ const command = key === "j" ? "prompt.autocomplete.next" : key === "k" ? "prompt.autocomplete.prev" : undefined
260
+ if (!command || !isCompletionToken(context) || !context.keymap.commands().some((item) => item.id === command)) return false
261
+ event.preventDefault()
262
+ event.stopPropagation()
263
+ context.keymap.dispatch(command)
264
+ return true
265
+ }
266
+
267
+ function isCompletionToken(context: Context) {
268
+ const input = context.renderer.currentFocusedEditor
269
+ const text = input?.plainText ?? ""
270
+ const index = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0))
271
+ const before = text.slice(0, Math.min(index + 1, text.length))
272
+ return /^\/\S*$/.test(before) || /(?:^|\s)@\S*$/.test(before)
273
+ }
274
+
275
+ function isArrowKey(key: string) {
276
+ return key === "<Left>" || key === "<Down>" || key === "<Up>" || key === "<Right>"
277
+ }
278
+
279
+ function readablePending(sequence: string) {
280
+ return sequence ? sequence.replaceAll("><", " ") : undefined
281
+ }
package/view.tsx ADDED
@@ -0,0 +1,46 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TextRenderable } from "@opentui/core"
3
+ import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
4
+ import type { VimMode } from "./src/modules/vim/state"
5
+
6
+ type VimStatusProps = {
7
+ mode: Accessor<VimMode>
8
+ pending: Accessor<string | undefined>
9
+ enabled: Accessor<boolean>
10
+ theme: {
11
+ info: TextRenderable["fg"]
12
+ success: TextRenderable["fg"]
13
+ warning: TextRenderable["fg"]
14
+ textMuted: TextRenderable["fg"]
15
+ }
16
+ pendingDisplayDelay?: number
17
+ }
18
+
19
+ export function VimStatus(props: VimStatusProps) {
20
+ const [pending, setPending] = createSignal<string>()
21
+ createEffect(() => {
22
+ const value = props.enabled() ? props.pending() : undefined
23
+ const delay = props.pendingDisplayDelay ?? 120
24
+ if (!value || delay <= 0) {
25
+ setPending(value)
26
+ return
27
+ }
28
+ const timer = setTimeout(() => setPending(value), delay)
29
+ onCleanup(() => clearTimeout(timer))
30
+ })
31
+
32
+ return (
33
+ <box paddingRight={1} flexDirection="row">
34
+ <text fg={props.theme.info}>{props.enabled() && pending() ? `${pending()} ` : ""}</text>
35
+ <text fg={props.mode() === "insert" ? props.theme.success : props.theme.warning}>
36
+ {props.enabled() ? modeLabel(props.mode()) : ""}
37
+ </text>
38
+ </box>
39
+ )
40
+ }
41
+
42
+ function modeLabel(mode: VimMode) {
43
+ if (mode === "visual") return "VISUAL"
44
+ if (mode === "visual-line") return "VISUAL LINE"
45
+ return mode === "normal" ? "NORMAL" : "INSERT"
46
+ }
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
- }