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/tui.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { Plugin } from "@opencode/plugin/tui"
3
- import { InputRenderable, type CursorStyleOptions, type KeyEvent } from "@opentui/core"
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
- consumed = vimee.handle(event as never, key, ctx as never)
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(config.defaultMode)
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 { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
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>