opencode-vim 0.0.16 → 0.0.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-vim",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/Tarquinen/opencode-vim"
@@ -1,7 +1,6 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import { spawn } from "node:child_process"
3
3
  import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
4
- import { useKeyboard } from "@opentui/solid"
5
4
  import { RGBA, type ScrollBoxRenderable } from "@opentui/core"
6
5
  import type { TuiPluginApi, TuiPromptRef } from "@opencode-ai/plugin/tui"
7
6
  import type { PromptContext } from "../../prompt/types"
@@ -104,7 +103,7 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
104
103
  props.controller.navigate = undefined
105
104
  })
106
105
 
107
- const dialogBlockingInput = () => dialogOpen() || dialogHandoffUntil() > Date.now()
106
+ const dialogBlockingInput = () => props.ctx.api.ui.dialog.open || dialogOpen() || dialogHandoffUntil() > Date.now()
108
107
  const match = createMemo(() => {
109
108
  if (props.ctx.disabled || props.ctx.visible === false) return undefined
110
109
  return findTrailingHashtagTrigger(input())
@@ -303,7 +302,7 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
303
302
  }, 0)
304
303
  })
305
304
 
306
- useKeyboard((event) => {
305
+ const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => {
307
306
  const ref = props.ctx.prompt()
308
307
  const name = event.name?.toLowerCase()
309
308
 
@@ -341,6 +340,8 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
341
340
  schedulePromptSync()
342
341
  })
343
342
 
343
+ onCleanup(() => offKeyboard())
344
+
344
345
  return (
345
346
  <Show when={visible()}>
346
347
  <box position="absolute" top={-menuHeight()} left={0} right={0} zIndex={100} borderColor={props.ctx.api.theme.current.border} {...INLINE_BORDER}>
@@ -1,6 +1,5 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { KeyEvent, ParsedKey } from "@opentui/core"
3
- import { useKeyboard } from "@opentui/solid"
4
3
  import { onCleanup } from "solid-js"
5
4
  import type { Accessor } from "solid-js"
6
5
  import type { PromptContext, PromptModule } from "../../prompt/types"
@@ -31,7 +30,7 @@ export function createVimModule(options?: unknown, enabled: Accessor<boolean> =
31
30
  return <VimKeyboard ctx={ctx} config={config} state={state} snippets={snippets} enabled={enabled} log={log} />
32
31
  },
33
32
  renderRight(ctx) {
34
- return <VimStatus mode={state.mode} pending={() => readablePending(state.pending())} enabled={enabled} theme={ctx.api.theme.current} pendingDisplayDelay={config.pendingDisplayDelay} disabled={ctx.disabled} log={log} requestRender={ctx.requestRender} />
33
+ return <VimStatus mode={state.mode} pending={() => readablePending(state.pending())} subscribe={state.subscribe} enabled={enabled} theme={ctx.api.theme.current} pendingDisplayDelay={config.pendingDisplayDelay} disabled={ctx.disabled} log={log} requestRender={ctx.requestRender} />
35
34
  },
36
35
  }
37
36
  }
@@ -44,13 +43,13 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
44
43
 
45
44
  const cursorStyleTimer = setInterval(syncCursorStyle, 50)
46
45
  const offKeyIntercept = props.ctx.api.keymap.intercept("key", ({ event }) => {
47
- if (!props.enabled() || props.ctx.disabled || props.ctx.visible === false) return
46
+ if (!canHandleKeys(props)) return
48
47
  const key = keyNotation(event)
49
48
  if (!key || !passThroughKey(event, key, props.state.mode())) return
50
49
  if (preparePassThroughKey(props.ctx, key, props.state.mode())) preparedEvents.add(event)
51
50
  }, { priority: 100 })
52
51
 
53
- useKeyboard((event) => {
52
+ const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => {
54
53
  props.log("keyboard.event", {
55
54
  name: event.name,
56
55
  ctrl: event.ctrl,
@@ -62,8 +61,8 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
62
61
  pending: props.state.pending(),
63
62
  })
64
63
 
65
- if (!props.enabled() || props.ctx.disabled || props.ctx.visible === false) {
66
- props.log("keyboard.skip", { enabled: props.enabled(), disabled: props.ctx.disabled, visible: props.ctx.visible })
64
+ if (!canHandleKeys(props)) {
65
+ props.log("keyboard.skip", { enabled: props.enabled(), disabled: props.ctx.disabled, visible: props.ctx.visible, dialogOpen: props.ctx.api.ui.dialog.open })
67
66
  return
68
67
  }
69
68
 
@@ -96,6 +95,7 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
96
95
  onCleanup(() => {
97
96
  props.log("keyboard.cleanup", { kind: props.ctx.kind })
98
97
  offKeyIntercept()
98
+ offKeyboard()
99
99
  vimee.cleanup()
100
100
  clearInterval(cursorStyleTimer)
101
101
  })
@@ -112,6 +112,10 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
112
112
  }
113
113
  }
114
114
 
115
+ function canHandleKeys(props: { ctx: PromptContext; enabled: Accessor<boolean> }) {
116
+ return props.enabled() && !props.ctx.disabled && props.ctx.visible !== false && !props.ctx.api.ui.dialog.open
117
+ }
118
+
115
119
  function readablePending(sequence: string) {
116
120
  if (!sequence) return undefined
117
121
  return sequence.replaceAll("><", " ")
@@ -2,23 +2,37 @@ import { createSignal } from "solid-js"
2
2
  import type { VimLog } from "./log"
3
3
 
4
4
  export type VimMode = "normal" | "insert" | "visual" | "visual-line"
5
+ export type VimStateListener = () => void
5
6
 
6
7
  export function createVimState(defaultMode: VimMode, log: VimLog = () => {}) {
7
8
  const [mode, setMode] = createSignal<VimMode>(defaultMode)
8
9
  const [pending, setPending] = createSignal("")
10
+ const listeners = new Set<VimStateListener>()
9
11
 
10
12
  log("state.init", { mode: defaultMode })
11
13
 
12
14
  return {
13
15
  mode,
14
16
  setMode(next: VimMode) {
15
- if (mode() !== next) log("state.mode", { from: mode(), to: next })
17
+ const changed = mode() !== next
18
+ if (changed) log("state.mode", { from: mode(), to: next })
16
19
  setMode(next)
20
+ if (changed) notify()
17
21
  },
18
22
  pending,
19
23
  setPending(next: string) {
20
- if (pending() !== next) log("state.pending", { from: pending(), to: next })
24
+ const changed = pending() !== next
25
+ if (changed) log("state.pending", { from: pending(), to: next })
21
26
  setPending(next)
27
+ if (changed) notify()
22
28
  },
29
+ subscribe(listener: VimStateListener) {
30
+ listeners.add(listener)
31
+ return () => listeners.delete(listener)
32
+ },
33
+ }
34
+
35
+ function notify() {
36
+ for (const listener of listeners) listener()
23
37
  }
24
38
  }
@@ -1,15 +1,17 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { createSignal, onCleanup } from "solid-js"
2
+ import type { TextRenderable } from "@opentui/core"
3
+ import { onCleanup } from "solid-js"
3
4
  import type { Accessor } from "solid-js"
4
5
  import type { PromptContext } from "../../prompt/types"
5
6
  import type { VimLog } from "./log"
6
- import type { VimMode } from "./state"
7
+ import type { VimMode, VimStateListener } from "./state"
7
8
 
8
9
  const STATUS_SYNC_MS = 50
9
10
 
10
11
  type VimStatusProps = {
11
12
  mode: Accessor<VimMode>
12
13
  pending: Accessor<string | undefined>
14
+ subscribe: (listener: VimStateListener) => () => void
13
15
  enabled: Accessor<boolean>
14
16
  theme: PromptContext["api"]["theme"]["current"]
15
17
  pendingDisplayDelay?: number
@@ -19,45 +21,63 @@ type VimStatusProps = {
19
21
  }
20
22
 
21
23
  export function VimStatus(props: VimStatusProps) {
22
- const [mode, setMode] = createSignal(props.mode())
23
- const [pending, setPending] = createSignal<string | undefined>()
24
- const [enabled, setEnabled] = createSignal(props.enabled())
24
+ let modeText: TextRenderable | undefined
25
+ let pendingText: TextRenderable | undefined
26
+ let displayedMode = props.mode()
27
+ let displayedPending: string | undefined
28
+ let displayedEnabled = props.enabled()
25
29
  let pendingTimer: ReturnType<typeof setTimeout> | undefined
26
30
  let scheduledPending: string | undefined
27
31
 
28
32
  const sync = () => {
29
33
  const nextMode = props.mode()
30
34
  const nextPending = props.pending()
35
+ let changed = false
31
36
 
32
- if (mode() !== nextMode) {
33
- props.log?.("status.sync", { fromMode: mode(), toMode: nextMode, fromPending: pending(), toPending: pending() })
34
- setMode(nextMode)
35
- props.requestRender?.()
37
+ if (displayedMode !== nextMode) {
38
+ props.log?.("status.sync", { fromMode: displayedMode, toMode: nextMode, fromPending: displayedPending, toPending: displayedPending })
39
+ displayedMode = nextMode
40
+ changed = true
36
41
  }
37
42
 
38
43
  const nextEnabled = props.enabled()
39
- if (enabled() !== nextEnabled) {
40
- props.log?.("status.enabled", { from: enabled(), to: nextEnabled })
41
- setEnabled(nextEnabled)
42
- props.requestRender?.()
44
+ if (displayedEnabled !== nextEnabled) {
45
+ props.log?.("status.enabled", { from: displayedEnabled, to: nextEnabled })
46
+ displayedEnabled = nextEnabled
47
+ changed = true
43
48
  }
44
49
 
50
+ if (changed) updateModeText()
45
51
  syncPending(nextPending)
52
+ if (changed) props.requestRender?.()
46
53
  }
47
54
 
55
+ const unsubscribe = props.subscribe(sync)
48
56
  const timer = setInterval(sync, STATUS_SYNC_MS)
49
57
  onCleanup(() => {
58
+ unsubscribe()
50
59
  clearInterval(timer)
51
60
  if (pendingTimer) clearTimeout(pendingTimer)
52
61
  })
53
62
 
54
63
  return (
55
64
  <box paddingLeft={1} paddingRight={1} flexDirection="row">
56
- {enabled() && pending() ? <text fg={props.theme.info}>{pending()} </text> : undefined}
57
- {enabled() ? <text fg={props.disabled ? props.theme.textMuted : mode() === "insert" ? props.theme.success : props.theme.warning}>{modeLabel(mode())}</text> : undefined}
65
+ <text ref={(ref: TextRenderable) => { pendingText = ref; updatePendingText() }} fg={props.theme.info}>{displayedPending ? `${displayedPending} ` : ""}</text>
66
+ <text ref={(ref: TextRenderable) => { modeText = ref; updateModeText() }} fg={modeColor(displayedMode, displayedEnabled, props.theme, props.disabled)}>{displayedEnabled ? modeLabel(displayedMode) : ""}</text>
58
67
  </box>
59
68
  )
60
69
 
70
+ function updateModeText() {
71
+ if (!modeText) return
72
+ modeText.content = displayedEnabled ? modeLabel(displayedMode) : ""
73
+ modeText.fg = modeColor(displayedMode, displayedEnabled, props.theme, props.disabled)
74
+ }
75
+
76
+ function updatePendingText() {
77
+ if (!pendingText) return
78
+ pendingText.content = displayedEnabled && displayedPending ? `${displayedPending} ` : ""
79
+ }
80
+
61
81
  function syncPending(nextPending: string | undefined) {
62
82
  if (!nextPending) {
63
83
  if (pendingTimer) clearTimeout(pendingTimer)
@@ -67,7 +87,7 @@ export function VimStatus(props: VimStatusProps) {
67
87
  return
68
88
  }
69
89
 
70
- if (pending() === nextPending) return
90
+ if (displayedPending === nextPending) return
71
91
  if (scheduledPending === nextPending) return
72
92
 
73
93
  if (pendingTimer) clearTimeout(pendingTimer)
@@ -87,13 +107,20 @@ export function VimStatus(props: VimStatusProps) {
87
107
  }
88
108
 
89
109
  function setDisplayedPending(nextPending: string | undefined) {
90
- if (pending() === nextPending) return
91
- props.log?.("status.sync", { fromMode: mode(), toMode: mode(), fromPending: pending(), toPending: nextPending })
92
- setPending(nextPending)
110
+ if (displayedPending === nextPending) return
111
+ props.log?.("status.sync", { fromMode: displayedMode, toMode: displayedMode, fromPending: displayedPending, toPending: nextPending })
112
+ displayedPending = nextPending
113
+ updatePendingText()
93
114
  props.requestRender?.()
94
115
  }
95
116
  }
96
117
 
118
+ function modeColor(mode: VimMode, enabled: boolean, theme: PromptContext["api"]["theme"]["current"], disabled?: boolean) {
119
+ if (!enabled) return undefined
120
+ if (disabled) return theme.textMuted
121
+ return mode === "insert" ? theme.success : theme.warning
122
+ }
123
+
97
124
  function modeLabel(mode: VimMode) {
98
125
  if (mode === "visual") return "VISUAL"
99
126
  if (mode === "visual-line") return "VISUAL LINE"