opencode-vim 0.0.21 → 0.0.23
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 +17 -65
- package/assets/vim-in-motion-dialogs.gif +0 -0
- package/docs/configuration.md +50 -400
- package/docs/keymap-actions.md +60 -188
- package/docs/vim-behavior.md +93 -0
- package/package.json +1 -1
- package/src/clipboard.tsx +50 -0
- package/src/modules/vim/actions.ts +1 -4
- package/src/modules/vim/config.ts +12 -0
- package/src/modules/vim/map.ts +13 -121
- package/src/modules/vim/{state.ts → state.tsx} +2 -0
- package/src/modules/vim/vimee.ts +228 -163
- package/src/session.tsx +291 -0
- package/src/transcript-items.tsx +168 -0
- package/src/transcript.tsx +179 -0
- package/tui.tsx +73 -14
- package/view.tsx +2 -19
package/tui.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { Plugin } from "@opencode/plugin/tui"
|
|
3
|
-
import { InputRenderable,
|
|
4
|
-
import { createEffect, onCleanup } from "solid-js"
|
|
3
|
+
import { InputRenderable, KeyEvent, PasteEvent, type CursorStyleOptions } from "@opentui/core"
|
|
4
|
+
import { createEffect, createSignal, onCleanup } from "solid-js"
|
|
5
5
|
import { applyVimCursorStyle, focusedInput } from "./src/modules/vim/actions"
|
|
6
6
|
import { createVimConfig } from "./src/modules/vim/config"
|
|
7
7
|
import { editInput } from "./src/modules/vim/edit"
|
|
@@ -11,6 +11,8 @@ import { displayToChar } from "./src/modules/vim/map"
|
|
|
11
11
|
import { createVimState } from "./src/modules/vim/state"
|
|
12
12
|
import { createVimeeAdapter } from "./src/modules/vim/vimee"
|
|
13
13
|
import { VimStatus } from "./view"
|
|
14
|
+
import { SESSION_MODE, createSessionMode } from "./src/session"
|
|
15
|
+
import { createVimClipboard } from "./src/clipboard"
|
|
14
16
|
|
|
15
17
|
type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
|
|
16
18
|
|
|
@@ -25,10 +27,13 @@ function VimHost(props: { context: Context }) {
|
|
|
25
27
|
const config = createVimConfig(props.context.options)
|
|
26
28
|
const normalMappings = Object.keys(config.keymaps.normal ?? {})
|
|
27
29
|
const log = createVimLog(config)
|
|
30
|
+
const clipboard = createVimClipboard(props.context.renderer)
|
|
31
|
+
const clipboardOptions = { onYank: (text: string) => { void clipboard.write(text) }, readClipboard: clipboard.read }
|
|
28
32
|
const state = createVimState(config.defaultMode, log)
|
|
29
|
-
const vimee = createVimeeAdapter(state, config, log)
|
|
33
|
+
const vimee = createVimeeAdapter(state, config, log, clipboardOptions)
|
|
30
34
|
const dialogState = createVimState(config.defaultMode, log)
|
|
31
|
-
const dialogVimee = createVimeeAdapter(dialogState, config, log)
|
|
35
|
+
const dialogVimee = createVimeeAdapter(dialogState, config, log, clipboardOptions)
|
|
36
|
+
const [dialogFocused, setDialogFocused] = createSignal(false)
|
|
32
37
|
const promptVim = { state, vimee }
|
|
33
38
|
const dialogVim = { state: dialogState, vimee: dialogVimee }
|
|
34
39
|
let dialogInput: typeof props.context.renderer.currentFocusedEditor = null
|
|
@@ -38,17 +43,17 @@ function VimHost(props: { context: Context }) {
|
|
|
38
43
|
let cursorMode = ""
|
|
39
44
|
let cursorInput: typeof props.context.renderer.currentFocusedEditor = null
|
|
40
45
|
let originalCursorStyle: CursorStyleOptions | undefined
|
|
46
|
+
const session = createSessionMode(props.context, config, clipboard)
|
|
47
|
+
let pendingKeys: Array<KeyEvent | PasteEvent> | undefined
|
|
41
48
|
|
|
42
49
|
const removeStatus = props.context.ui.slot({
|
|
43
50
|
prepend: "prompt.footer",
|
|
44
51
|
render: (footer) =>
|
|
45
|
-
footer.mode === "normal" ? (
|
|
52
|
+
footer.mode === "normal" ? session.active() && !dialogFocused() ? <session.Status /> : (
|
|
46
53
|
<VimStatus
|
|
47
|
-
mode={state.mode}
|
|
48
|
-
pending={() => readablePending(state.pending())}
|
|
54
|
+
mode={() => dialogFocused() ? dialogState.mode() : state.mode()}
|
|
49
55
|
enabled={enabled}
|
|
50
56
|
theme={compatTheme(props.context)}
|
|
51
|
-
pendingDisplayDelay={config.pendingDisplayDelay}
|
|
52
57
|
/>
|
|
53
58
|
) : null,
|
|
54
59
|
})
|
|
@@ -69,6 +74,8 @@ function VimHost(props: { context: Context }) {
|
|
|
69
74
|
draft.enabled = next
|
|
70
75
|
})
|
|
71
76
|
if (!next) {
|
|
77
|
+
pendingKeys = undefined
|
|
78
|
+
session.close()
|
|
72
79
|
vimee.suspend()
|
|
73
80
|
dialogVimee.suspend()
|
|
74
81
|
}
|
|
@@ -81,6 +88,13 @@ function VimHost(props: { context: Context }) {
|
|
|
81
88
|
|
|
82
89
|
const onKey = (event: KeyEvent) => {
|
|
83
90
|
if (!enabled() || event.defaultPrevented) return
|
|
91
|
+
if (pendingKeys) {
|
|
92
|
+
pendingKeys.push(new KeyEvent(event))
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
event.stopPropagation()
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (props.context.keymap.mode.current() === SESSION_MODE) return
|
|
84
98
|
const kind = inputKind(props.context)
|
|
85
99
|
if (!kind) return
|
|
86
100
|
if (props.context.keymap.pending().length) return
|
|
@@ -88,6 +102,13 @@ function VimHost(props: { context: Context }) {
|
|
|
88
102
|
const key = keyNotation(event as never)
|
|
89
103
|
if (!key) return
|
|
90
104
|
const mapped = normalMappings.some((sequence) => sequence.startsWith(key))
|
|
105
|
+
if (kind === "prompt" && key === config.sessionKey && !mapped && state.mode() === "normal" && !vimee.isPending()) {
|
|
106
|
+
if (session.enter()) {
|
|
107
|
+
event.preventDefault()
|
|
108
|
+
event.stopPropagation()
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
}
|
|
91
112
|
if (passThroughKey(event, key, state.mode(), vimee.isPending(), mapped)) return
|
|
92
113
|
if (key === "<Esc>" && state.mode() === "normal" && !vimee.isPending()) return
|
|
93
114
|
|
|
@@ -110,7 +131,29 @@ function VimHost(props: { context: Context }) {
|
|
|
110
131
|
const before = state.mode()
|
|
111
132
|
let consumed = false
|
|
112
133
|
try {
|
|
113
|
-
|
|
134
|
+
const result = vimee.handle(event as never, key, ctx as never)
|
|
135
|
+
if (typeof result === "boolean") consumed = result
|
|
136
|
+
else {
|
|
137
|
+
consumed = true
|
|
138
|
+
const queued: Array<KeyEvent | PasteEvent> = []
|
|
139
|
+
pendingKeys = queued
|
|
140
|
+
const input = props.context.renderer.currentFocusedEditor
|
|
141
|
+
void result.then((handled) => {
|
|
142
|
+
if (pendingKeys !== queued) return
|
|
143
|
+
pendingKeys = undefined
|
|
144
|
+
if (!handled) return
|
|
145
|
+
syncCursor(true)
|
|
146
|
+
// Replay through the host too: a queued i may be followed by native typing.
|
|
147
|
+
for (const event of queued) {
|
|
148
|
+
if (!enabled() || props.context.renderer.currentFocusedEditor !== input) break
|
|
149
|
+
if (event instanceof PasteEvent) props.context.renderer.keyInput.emit("paste", event)
|
|
150
|
+
else props.context.renderer.keyInput.emit("keypress", event)
|
|
151
|
+
}
|
|
152
|
+
}).catch((error) => {
|
|
153
|
+
if (pendingKeys === queued) pendingKeys = undefined
|
|
154
|
+
log("vim.clipboard.paste.error", { error: String(error) })
|
|
155
|
+
})
|
|
156
|
+
}
|
|
114
157
|
} finally {
|
|
115
158
|
if (consumed || state.mode() !== before) {
|
|
116
159
|
event.preventDefault()
|
|
@@ -121,19 +164,37 @@ function VimHost(props: { context: Context }) {
|
|
|
121
164
|
}
|
|
122
165
|
|
|
123
166
|
const onFocus = () => {
|
|
167
|
+
pendingKeys = undefined
|
|
124
168
|
vimee.suspend()
|
|
125
169
|
dialogVimee.suspend()
|
|
126
170
|
syncCursor()
|
|
127
171
|
}
|
|
172
|
+
const onPaste = (event: PasteEvent) => {
|
|
173
|
+
if (!pendingKeys || event.defaultPrevented) return
|
|
174
|
+
pendingKeys.push(new PasteEvent(event.bytes, event.metadata))
|
|
175
|
+
event.preventDefault()
|
|
176
|
+
event.stopPropagation()
|
|
177
|
+
}
|
|
128
178
|
props.context.renderer.keyInput.prependListener("keypress", onKey)
|
|
179
|
+
props.context.renderer.keyInput.prependListener("paste", onPaste)
|
|
129
180
|
props.context.renderer.on("focused_editor", onFocus)
|
|
130
181
|
createEffect(() => syncCursor())
|
|
182
|
+
let route = props.context.ui.router.current()
|
|
183
|
+
createEffect(() => {
|
|
184
|
+
const next = props.context.ui.router.current()
|
|
185
|
+
if (next !== route && pendingKeys) onFocus()
|
|
186
|
+
route = next
|
|
187
|
+
})
|
|
131
188
|
onCleanup(() => {
|
|
189
|
+
pendingKeys = undefined
|
|
132
190
|
removeStatus()
|
|
191
|
+
session.close()
|
|
133
192
|
props.context.renderer.keyInput.off("keypress", onKey)
|
|
193
|
+
props.context.renderer.keyInput.off("paste", onPaste)
|
|
134
194
|
props.context.renderer.off("focused_editor", onFocus)
|
|
135
195
|
vimee.cleanup()
|
|
136
196
|
dialogVimee.cleanup()
|
|
197
|
+
void clipboard.dispose()
|
|
137
198
|
restoreCursor()
|
|
138
199
|
})
|
|
139
200
|
|
|
@@ -142,6 +203,7 @@ function VimHost(props: { context: Context }) {
|
|
|
142
203
|
function syncCursor(force = false) {
|
|
143
204
|
const input = props.context.renderer.currentFocusedEditor
|
|
144
205
|
const kind = inputKind(props.context)
|
|
206
|
+
setDialogFocused(kind === "dialog")
|
|
145
207
|
if (!enabled() || !kind || !input) {
|
|
146
208
|
restoreCursor()
|
|
147
209
|
return
|
|
@@ -150,7 +212,7 @@ function VimHost(props: { context: Context }) {
|
|
|
150
212
|
if (kind === "dialog" && dialogInput !== input) {
|
|
151
213
|
dialogInput = input
|
|
152
214
|
dialogVimee.suspend()
|
|
153
|
-
dialogState.setMode(
|
|
215
|
+
dialogState.setMode(promptVim.state.mode())
|
|
154
216
|
}
|
|
155
217
|
const mode = state.mode()
|
|
156
218
|
vimee.attach(ctx)
|
|
@@ -221,6 +283,7 @@ function createCompatContext(context: Context) {
|
|
|
221
283
|
}
|
|
222
284
|
|
|
223
285
|
function inputKind(context: Context): "prompt" | "dialog" | undefined {
|
|
286
|
+
if (context.keymap.mode.current() === SESSION_MODE) return
|
|
224
287
|
if (!context.renderer.currentFocusedEditor) return
|
|
225
288
|
const commands = context.keymap.commands()
|
|
226
289
|
if (context.keymap.mode.current() === "modal") {
|
|
@@ -275,7 +338,3 @@ function isCompletionToken(context: Context) {
|
|
|
275
338
|
function isArrowKey(key: string) {
|
|
276
339
|
return key === "<Left>" || key === "<Down>" || key === "<Up>" || key === "<Right>"
|
|
277
340
|
}
|
|
278
|
-
|
|
279
|
-
function readablePending(sequence: string) {
|
|
280
|
-
return sequence ? sequence.replaceAll("><", " ") : undefined
|
|
281
|
-
}
|
package/view.tsx
CHANGED
|
@@ -1,37 +1,20 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import type { TextRenderable } from "@opentui/core"
|
|
3
|
-
import {
|
|
3
|
+
import type { Accessor } from "solid-js"
|
|
4
4
|
import type { VimMode } from "./src/modules/vim/state"
|
|
5
5
|
|
|
6
6
|
type VimStatusProps = {
|
|
7
7
|
mode: Accessor<VimMode>
|
|
8
|
-
pending: Accessor<string | undefined>
|
|
9
8
|
enabled: Accessor<boolean>
|
|
10
9
|
theme: {
|
|
11
|
-
info: TextRenderable["fg"]
|
|
12
10
|
success: TextRenderable["fg"]
|
|
13
11
|
warning: TextRenderable["fg"]
|
|
14
|
-
textMuted: TextRenderable["fg"]
|
|
15
12
|
}
|
|
16
|
-
pendingDisplayDelay?: number
|
|
17
13
|
}
|
|
18
14
|
|
|
19
15
|
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
16
|
return (
|
|
33
|
-
<box paddingRight={1} flexDirection="row">
|
|
34
|
-
<text fg={props.theme.info}>{props.enabled() && pending() ? `${pending()} ` : ""}</text>
|
|
17
|
+
<box paddingRight={1} flexDirection="row" flexShrink={0}>
|
|
35
18
|
<text fg={props.mode() === "insert" ? props.theme.success : props.theme.warning}>
|
|
36
19
|
{props.enabled() ? modeLabel(props.mode()) : ""}
|
|
37
20
|
</text>
|