Skip to content

Commit c98d65e

Browse files
authored
Merge pull request #10 from Tarquinen/dev
Fix TUI keyboard handling
2 parents 5cc7a20 + 3a6be54 commit c98d65e

5 files changed

Lines changed: 69 additions & 27 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-vim",
3-
"version": "0.0.16",
3+
"version": "0.0.17",
44
"repository": {
55
"type": "git",
66
"url": "https://github.com/Tarquinen/opencode-vim"

src/modules/snippets/view.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/** @jsxImportSource @opentui/solid */
22
import { spawn } from "node:child_process"
33
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
4-
import { useKeyboard } from "@opentui/solid"
54
import { RGBA, type ScrollBoxRenderable } from "@opentui/core"
65
import type { TuiPluginApi, TuiPromptRef } from "@opencode-ai/plugin/tui"
76
import type { PromptContext } from "../../prompt/types"
@@ -303,7 +302,7 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
303302
}, 0)
304303
})
305304

306-
useKeyboard((event) => {
305+
const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => {
307306
const ref = props.ctx.prompt()
308307
const name = event.name?.toLowerCase()
309308

@@ -341,6 +340,8 @@ export function SnippetAutocomplete(props: SnippetAutocompleteProps) {
341340
schedulePromptSync()
342341
})
343342

343+
onCleanup(() => offKeyboard())
344+
344345
return (
345346
<Show when={visible()}>
346347
<box position="absolute" top={-menuHeight()} left={0} right={0} zIndex={100} borderColor={props.ctx.api.theme.current.border} {...INLINE_BORDER}>

src/modules/vim/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/** @jsxImportSource @opentui/solid */
22
import type { KeyEvent, ParsedKey } from "@opentui/core"
3-
import { useKeyboard } from "@opentui/solid"
43
import { onCleanup } from "solid-js"
54
import type { Accessor } from "solid-js"
65
import type { PromptContext, PromptModule } from "../../prompt/types"
@@ -31,7 +30,7 @@ export function createVimModule(options?: unknown, enabled: Accessor<boolean> =
3130
return <VimKeyboard ctx={ctx} config={config} state={state} snippets={snippets} enabled={enabled} log={log} />
3231
},
3332
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} />
3534
},
3635
}
3736
}
@@ -50,7 +49,7 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
5049
if (preparePassThroughKey(props.ctx, key, props.state.mode())) preparedEvents.add(event)
5150
}, { priority: 100 })
5251

53-
useKeyboard((event) => {
52+
const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => {
5453
props.log("keyboard.event", {
5554
name: event.name,
5655
ctrl: event.ctrl,
@@ -96,6 +95,7 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
9695
onCleanup(() => {
9796
props.log("keyboard.cleanup", { kind: props.ctx.kind })
9897
offKeyIntercept()
98+
offKeyboard()
9999
vimee.cleanup()
100100
clearInterval(cursorStyleTimer)
101101
})

src/modules/vim/state.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,37 @@ import { createSignal } from "solid-js"
22
import type { VimLog } from "./log"
33

44
export type VimMode = "normal" | "insert" | "visual" | "visual-line"
5+
export type VimStateListener = () => void
56

67
export function createVimState(defaultMode: VimMode, log: VimLog = () => {}) {
78
const [mode, setMode] = createSignal<VimMode>(defaultMode)
89
const [pending, setPending] = createSignal("")
10+
const listeners = new Set<VimStateListener>()
911

1012
log("state.init", { mode: defaultMode })
1113

1214
return {
1315
mode,
1416
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 })
1619
setMode(next)
20+
if (changed) notify()
1721
},
1822
pending,
1923
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 })
2126
setPending(next)
27+
if (changed) notify()
2228
},
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()
2337
}
2438
}

src/modules/vim/view.tsx

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
/** @jsxImportSource @opentui/solid */
2-
import { createSignal, onCleanup } from "solid-js"
2+
import type { TextRenderable } from "@opentui/core"
3+
import { onCleanup } from "solid-js"
34
import type { Accessor } from "solid-js"
45
import type { PromptContext } from "../../prompt/types"
56
import type { VimLog } from "./log"
6-
import type { VimMode } from "./state"
7+
import type { VimMode, VimStateListener } from "./state"
78

89
const STATUS_SYNC_MS = 50
910

1011
type VimStatusProps = {
1112
mode: Accessor<VimMode>
1213
pending: Accessor<string | undefined>
14+
subscribe: (listener: VimStateListener) => () => void
1315
enabled: Accessor<boolean>
1416
theme: PromptContext["api"]["theme"]["current"]
1517
pendingDisplayDelay?: number
@@ -19,45 +21,63 @@ type VimStatusProps = {
1921
}
2022

2123
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()
2529
let pendingTimer: ReturnType<typeof setTimeout> | undefined
2630
let scheduledPending: string | undefined
2731

2832
const sync = () => {
2933
const nextMode = props.mode()
3034
const nextPending = props.pending()
35+
let changed = false
3136

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
3641
}
3742

3843
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
4348
}
4449

50+
if (changed) updateModeText()
4551
syncPending(nextPending)
52+
if (changed) props.requestRender?.()
4653
}
4754

55+
const unsubscribe = props.subscribe(sync)
4856
const timer = setInterval(sync, STATUS_SYNC_MS)
4957
onCleanup(() => {
58+
unsubscribe()
5059
clearInterval(timer)
5160
if (pendingTimer) clearTimeout(pendingTimer)
5261
})
5362

5463
return (
5564
<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>
5867
</box>
5968
)
6069

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+
6181
function syncPending(nextPending: string | undefined) {
6282
if (!nextPending) {
6383
if (pendingTimer) clearTimeout(pendingTimer)
@@ -67,7 +87,7 @@ export function VimStatus(props: VimStatusProps) {
6787
return
6888
}
6989

70-
if (pending() === nextPending) return
90+
if (displayedPending === nextPending) return
7191
if (scheduledPending === nextPending) return
7292

7393
if (pendingTimer) clearTimeout(pendingTimer)
@@ -87,13 +107,20 @@ export function VimStatus(props: VimStatusProps) {
87107
}
88108

89109
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()
93114
props.requestRender?.()
94115
}
95116
}
96117

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+
97124
function modeLabel(mode: VimMode) {
98125
if (mode === "visual") return "VISUAL"
99126
if (mode === "visual-line") return "VISUAL LINE"

0 commit comments

Comments
 (0)