opencode-vim 0.0.2

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.
@@ -0,0 +1,119 @@
1
+ import type { VimMode } from "./state"
2
+
3
+ export type VimCursorStyle = {
4
+ style: "block" | "line" | "underline" | "default"
5
+ blinking?: boolean
6
+ }
7
+
8
+ export type VimConfig = {
9
+ defaultMode: VimMode
10
+ keymapTimeout: number
11
+ pendingDisplayDelay: number
12
+ cursorStyles: Record<VimMode, VimCursorStyle>
13
+ debug: boolean
14
+ debugPath?: string
15
+ keymaps: VimKeymaps
16
+ }
17
+
18
+ export type VimOptions = {
19
+ defaultMode?: VimMode
20
+ keymapTimeout?: number
21
+ pendingDisplayDelay?: number
22
+ cursorStyles?: Partial<Record<VimMode, VimCursorStyle>>
23
+ debug?: boolean
24
+ debugPath?: string
25
+ keymaps?: VimKeymaps
26
+ }
27
+
28
+ export type VimKeymaps = Partial<Record<VimMode, Record<string, VimMappedAction>>>
29
+ export type VimMappedAction = string
30
+
31
+ const DEFAULT_CURSOR_STYLES: Record<VimMode, VimCursorStyle> = {
32
+ insert: { style: "line", blinking: true },
33
+ normal: { style: "block", blinking: true },
34
+ }
35
+
36
+ export function createVimConfig(options: unknown): VimConfig {
37
+ const input = readOptions(options)
38
+ return {
39
+ defaultMode: input.defaultMode ?? "insert",
40
+ keymapTimeout: Math.max(0, input.keymapTimeout ?? 500),
41
+ pendingDisplayDelay: Math.max(0, input.pendingDisplayDelay ?? 120),
42
+ cursorStyles: {
43
+ insert: { ...DEFAULT_CURSOR_STYLES.insert, ...input.cursorStyles?.insert },
44
+ normal: { ...DEFAULT_CURSOR_STYLES.normal, ...input.cursorStyles?.normal },
45
+ },
46
+ debug: input.debug ?? process.env.VIM_PROMPT_DEBUG === "1",
47
+ debugPath: input.debugPath,
48
+ keymaps: input.keymaps ?? {},
49
+ }
50
+ }
51
+
52
+ function readOptions(options: unknown): VimOptions {
53
+ if (!options || typeof options !== "object") return {}
54
+ const raw = "vim" in options ? (options as { vim?: unknown }).vim : options
55
+ if (!raw || typeof raw !== "object") return {}
56
+
57
+ const source = raw as Record<string, unknown>
58
+ return {
59
+ defaultMode: isMode(source.defaultMode) ? source.defaultMode : undefined,
60
+ keymapTimeout: readNumber(source.keymapTimeout),
61
+ pendingDisplayDelay: typeof source.pendingDisplayDelay === "number" ? source.pendingDisplayDelay : undefined,
62
+ cursorStyles: readCursorStyles(source.cursorStyles),
63
+ debug: typeof source.debug === "boolean" ? source.debug : undefined,
64
+ debugPath: typeof source.debugPath === "string" ? source.debugPath : undefined,
65
+ keymaps: readKeymaps(source.keymaps),
66
+ }
67
+ }
68
+
69
+ function readKeymaps(input: unknown): VimKeymaps | undefined {
70
+ if (!input || typeof input !== "object") return undefined
71
+ const source = input as Record<string, unknown>
72
+ const keymaps: VimKeymaps = {}
73
+
74
+ for (const mode of ["insert", "normal"] as const) {
75
+ const raw = source[mode]
76
+ if (!raw || typeof raw !== "object") continue
77
+ keymaps[mode] = {}
78
+ for (const [key, action] of Object.entries(raw as Record<string, unknown>)) {
79
+ if (isMappedAction(action)) keymaps[mode][key] = action
80
+ }
81
+ }
82
+
83
+ return keymaps
84
+ }
85
+
86
+ function readCursorStyles(input: unknown): VimOptions["cursorStyles"] {
87
+ if (!input || typeof input !== "object") return undefined
88
+ const source = input as Record<string, unknown>
89
+ return {
90
+ insert: readCursorStyle(source.insert),
91
+ normal: readCursorStyle(source.normal),
92
+ }
93
+ }
94
+
95
+ function readCursorStyle(input: unknown): VimCursorStyle | undefined {
96
+ if (!input || typeof input !== "object") return undefined
97
+ const source = input as Record<string, unknown>
98
+ if (!isCursorStyle(source.style)) return undefined
99
+ return {
100
+ style: source.style,
101
+ blinking: typeof source.blinking === "boolean" ? source.blinking : undefined,
102
+ }
103
+ }
104
+
105
+ function isCursorStyle(value: unknown): value is VimCursorStyle["style"] {
106
+ return value === "block" || value === "line" || value === "underline" || value === "default"
107
+ }
108
+
109
+ function isMode(value: unknown): value is VimMode {
110
+ return value === "insert" || value === "normal"
111
+ }
112
+
113
+ function isMappedAction(value: unknown): value is VimMappedAction {
114
+ return typeof value === "string" && value.length > 0
115
+ }
116
+
117
+ function readNumber(value: unknown) {
118
+ return typeof value === "number" ? value : undefined
119
+ }
@@ -0,0 +1,96 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { useKeyboard } from "@opentui/solid"
3
+ import { onCleanup } from "solid-js"
4
+ import type { PromptContext, PromptModule } from "../../prompt/types"
5
+ import { applyVimCursorStyle } from "./actions"
6
+ import type { VimConfig } from "./config"
7
+ import { createVimConfig } from "./config"
8
+ import { keyNotation } from "./keys"
9
+ import { createVimLog } from "./log"
10
+ import type { VimLog } from "./log"
11
+ import { createVimState } from "./state"
12
+ import { createVimeeAdapter } from "./vimee"
13
+ import { VimStatus } from "./view"
14
+
15
+ export function createVimModule(options?: unknown): PromptModule {
16
+ const config = createVimConfig(options)
17
+ const log = createVimLog(config)
18
+ const state = createVimState(config.defaultMode, log)
19
+ log("module.create", { defaultMode: config.defaultMode, keymapTimeout: config.keymapTimeout })
20
+
21
+ return {
22
+ id: "vim",
23
+ order: 0,
24
+ setup(ctx) {
25
+ log("module.setup", { kind: ctx.kind, sessionID: ctx.sessionID, workspaceID: ctx.workspaceID })
26
+ },
27
+ renderAbove(ctx) {
28
+ return <VimKeyboard ctx={ctx} config={config} state={state} log={log} />
29
+ },
30
+ renderRight(ctx) {
31
+ return <VimStatus mode={state.mode} pending={() => readablePending(state.pending())} theme={ctx.api.theme.current} pendingDisplayDelay={config.pendingDisplayDelay} disabled={ctx.disabled} log={log} requestRender={ctx.requestRender} />
32
+ },
33
+ }
34
+ }
35
+
36
+ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType<typeof createVimState>; log: VimLog }) {
37
+ let cursorStyleMode = ""
38
+ const vimee = createVimeeAdapter(props.state, props.config, props.log)
39
+ props.log("keyboard.mount", { kind: props.ctx.kind })
40
+
41
+ const cursorStyleTimer = setInterval(syncCursorStyle, 50)
42
+
43
+ useKeyboard((event) => {
44
+ props.log("keyboard.event", {
45
+ name: event.name,
46
+ ctrl: event.ctrl,
47
+ meta: event.meta,
48
+ shift: event.shift,
49
+ defaultPrevented: event.defaultPrevented,
50
+ propagationStopped: event.propagationStopped,
51
+ mode: props.state.mode(),
52
+ pending: props.state.pending(),
53
+ })
54
+
55
+ if (props.ctx.disabled || props.ctx.visible === false) {
56
+ props.log("keyboard.skip", { disabled: props.ctx.disabled, visible: props.ctx.visible })
57
+ return
58
+ }
59
+
60
+ const key = keyNotation(event)
61
+ if (!key) {
62
+ props.log("keyboard.no_key")
63
+ return
64
+ }
65
+
66
+ const consumed = vimee.handle(event, key, props.ctx)
67
+ if (consumed) {
68
+ event.preventDefault()
69
+ event.stopPropagation()
70
+ }
71
+ syncCursorStyle(true)
72
+ props.ctx.requestRender()
73
+ })
74
+
75
+ onCleanup(() => {
76
+ props.log("keyboard.cleanup", { kind: props.ctx.kind })
77
+ vimee.cleanup()
78
+ clearInterval(cursorStyleTimer)
79
+ })
80
+
81
+ return <box height={0} />
82
+
83
+ function syncCursorStyle(force = false) {
84
+ const mode = props.state.mode()
85
+ if (!force && cursorStyleMode === mode) return
86
+ if (applyVimCursorStyle(props.ctx, props.config.cursorStyles[mode])) {
87
+ cursorStyleMode = mode
88
+ props.log("cursor.style", { mode, style: props.config.cursorStyles[mode].style, blinking: props.config.cursorStyles[mode].blinking })
89
+ }
90
+ }
91
+ }
92
+
93
+ function readablePending(sequence: string) {
94
+ if (!sequence) return undefined
95
+ return sequence.replaceAll("><", " ")
96
+ }
@@ -0,0 +1,34 @@
1
+ import type { KeyEvent } from "@opentui/core"
2
+
3
+ const NAMED_KEYS: Record<string, string> = {
4
+ escape: "<Esc>",
5
+ esc: "<Esc>",
6
+ return: "<CR>",
7
+ enter: "<CR>",
8
+ tab: "<Tab>",
9
+ backspace: "<BS>",
10
+ delete: "<Del>",
11
+ space: "<Space>",
12
+ up: "<Up>",
13
+ down: "<Down>",
14
+ left: "<Left>",
15
+ right: "<Right>",
16
+ home: "<Home>",
17
+ end: "<End>",
18
+ }
19
+
20
+ export function keyNotation(event: KeyEvent) {
21
+ const name = event.name?.toLowerCase()
22
+ if (!name) return undefined
23
+
24
+ if (event.ctrl) return `<C-${ctrlKey(name)}>`
25
+ if (event.meta) return `<M-${name}>`
26
+ if (name.length === 1) return event.shift ? name.toUpperCase() : name
27
+ return NAMED_KEYS[name] ?? `<${name}>`
28
+ }
29
+
30
+ function ctrlKey(name: string) {
31
+ if (name === "escape" || name === "esc") return "["
32
+ if (name.length === 1) return name
33
+ return name
34
+ }
@@ -0,0 +1,37 @@
1
+ import { appendFile, mkdir } from "node:fs/promises"
2
+ import { dirname, join } from "node:path"
3
+ import type { VimConfig } from "./config"
4
+
5
+ export type VimLog = (event: string, data?: Record<string, unknown>) => void
6
+
7
+ export function createVimLog(config: VimConfig): VimLog {
8
+ if (!config.debug) return () => {}
9
+
10
+ const path = config.debugPath ?? defaultPath()
11
+ void append(path, `\n--- opencode-vim debug ${new Date().toISOString()} ---\n`)
12
+
13
+ return (event, data) => {
14
+ const suffix = data ? ` ${safeJson(data)}` : ""
15
+ void append(path, `${new Date().toISOString()} ${event}${suffix}\n`)
16
+ }
17
+ }
18
+
19
+ function defaultPath() {
20
+ const home = process.env.HOME
21
+ return home ? join(home, ".cache/opencode/opencode-vim.log") : "/tmp/opencode-vim.log"
22
+ }
23
+
24
+ async function append(path: string, text: string) {
25
+ try {
26
+ await mkdir(dirname(path), { recursive: true })
27
+ await appendFile(path, text)
28
+ } catch {}
29
+ }
30
+
31
+ function safeJson(data: Record<string, unknown>) {
32
+ try {
33
+ return JSON.stringify(data)
34
+ } catch {
35
+ return "{}"
36
+ }
37
+ }
@@ -0,0 +1,159 @@
1
+ import type { CursorPosition } from "@vimee/core"
2
+ import type { EditBufferLike } from "./actions"
3
+
4
+ export type PromptMap = {
5
+ hostText: string
6
+ vimText: string
7
+ hostToVim: number[]
8
+ vimToHost: Array<number | undefined>
9
+ }
10
+
11
+ export function createPromptMap(hostText: string, input?: EditBufferLike): PromptMap {
12
+ return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [])
13
+ }
14
+
15
+ export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
16
+ const prefix = commonPrefix(map.vimText, vimText)
17
+ const suffix = commonSuffix(map.vimText, vimText, prefix)
18
+ const synthetic = new Set<number>()
19
+ let hostText = ""
20
+
21
+ for (let vimOffset = 0; vimOffset < vimText.length; vimOffset++) {
22
+ const oldOffset = previousOffset(map, vimOffset, vimText.length, prefix, suffix)
23
+ if (oldOffset !== undefined && map.vimToHost[oldOffset] === undefined) {
24
+ synthetic.add(vimOffset)
25
+ continue
26
+ }
27
+
28
+ hostText += vimText[vimOffset]
29
+ }
30
+
31
+ return buildPromptMapFromSynthetic(hostText, vimText, synthetic)
32
+ }
33
+
34
+ export function hostPosition(map: PromptMap, hostOffset: number): CursorPosition {
35
+ return positionFromOffset(map.vimText, map.hostToVim[clamp(hostOffset, 0, map.hostText.length)] ?? 0)
36
+ }
37
+
38
+ export function hostOffset(map: PromptMap, position: CursorPosition, bias: "previous" | "next" = "next") {
39
+ return hostOffsetFromVimOffset(map, offsetFromPosition(map.vimText, position), bias)
40
+ }
41
+
42
+ function buildPromptMap(hostText: string, wraps: number[]): PromptMap {
43
+ const hostToVim: number[] = []
44
+ const vimToHost: Array<number | undefined> = []
45
+ const wrapOffsets = new Set(wraps.filter((offset) => offset > 0 && offset < hostText.length && hostText[offset - 1] !== "\n"))
46
+ let vimText = ""
47
+ let vimOffset = 0
48
+
49
+ for (let hostOffset = 0; hostOffset < hostText.length; hostOffset++) {
50
+ if (wrapOffsets.has(hostOffset)) {
51
+ vimText += "\n"
52
+ vimToHost[vimOffset] = undefined
53
+ vimOffset++
54
+ }
55
+ hostToVim[hostOffset] = vimOffset
56
+ vimText += hostText[hostOffset]
57
+ vimToHost[vimOffset] = hostOffset
58
+ vimOffset++
59
+ }
60
+
61
+ hostToVim[hostText.length] = vimOffset
62
+ return { hostText, vimText, hostToVim, vimToHost }
63
+ }
64
+
65
+ function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number>): PromptMap {
66
+ const hostToVim: number[] = []
67
+ const vimToHost: Array<number | undefined> = []
68
+ let hostOffset = 0
69
+
70
+ for (let vimOffset = 0; vimOffset < vimText.length; vimOffset++) {
71
+ if (synthetic.has(vimOffset)) {
72
+ vimToHost[vimOffset] = undefined
73
+ continue
74
+ }
75
+
76
+ hostToVim[hostOffset] = vimOffset
77
+ vimToHost[vimOffset] = hostOffset
78
+ hostOffset++
79
+ }
80
+
81
+ hostToVim[hostText.length] = vimText.length
82
+ return { hostText, vimText, hostToVim, vimToHost }
83
+ }
84
+
85
+ function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, prefix: number, suffix: number) {
86
+ if (vimOffset < prefix) return vimOffset
87
+ if (vimOffset >= vimLength - suffix) return map.vimText.length - (vimLength - vimOffset)
88
+ return undefined
89
+ }
90
+
91
+ function visualWrapOffsets(input: EditBufferLike, text: string) {
92
+ const original = input.cursorOffset
93
+ const wraps: number[] = []
94
+ let previousRow: number | undefined
95
+
96
+ for (let offset = 0; offset <= text.length; offset++) {
97
+ input.cursorOffset = offset
98
+ const row = input.visualCursor?.visualRow
99
+ if (row === undefined) {
100
+ wraps.length = 0
101
+ break
102
+ }
103
+ if (previousRow !== undefined && row > previousRow && text[offset - 1] !== "\n") wraps.push(offset)
104
+ previousRow = row
105
+ }
106
+
107
+ input.cursorOffset = original
108
+ return wraps
109
+ }
110
+
111
+ function hostOffsetFromVimOffset(map: PromptMap, vimOffset: number, bias: "previous" | "next") {
112
+ const offset = clamp(vimOffset, 0, map.vimText.length)
113
+ if (offset === map.vimText.length) return map.hostText.length
114
+
115
+ const host = map.vimToHost[offset]
116
+ if (host !== undefined) return host
117
+
118
+ if (bias === "previous") {
119
+ for (let previous = offset - 1; previous >= 0; previous--) {
120
+ const previousHost = map.vimToHost[previous]
121
+ if (previousHost !== undefined) return previousHost
122
+ }
123
+ }
124
+
125
+ for (let next = offset + 1; next < map.vimToHost.length; next++) {
126
+ const nextHost = map.vimToHost[next]
127
+ if (nextHost !== undefined) return nextHost
128
+ }
129
+ return map.hostText.length
130
+ }
131
+
132
+ function commonPrefix(left: string, right: string) {
133
+ let index = 0
134
+ while (index < left.length && index < right.length && left[index] === right[index]) index++
135
+ return index
136
+ }
137
+
138
+ function commonSuffix(left: string, right: string, prefix: number) {
139
+ let length = 0
140
+ while (length + prefix < left.length && length + prefix < right.length && left[left.length - length - 1] === right[right.length - length - 1]) length++
141
+ return length
142
+ }
143
+
144
+ function positionFromOffset(text: string, offset: number): CursorPosition {
145
+ const lines = text.slice(0, offset).split("\n")
146
+ return { line: lines.length - 1, col: lines[lines.length - 1]?.length ?? 0 }
147
+ }
148
+
149
+ function offsetFromPosition(text: string, position: CursorPosition) {
150
+ const lines = text.split("\n")
151
+ const line = clamp(position.line, 0, Math.max(0, lines.length - 1))
152
+ let offset = 0
153
+ for (let index = 0; index < line; index++) offset += lines[index].length + 1
154
+ return offset + clamp(position.col, 0, lines[line]?.length ?? 0)
155
+ }
156
+
157
+ function clamp(value: number, min: number, max: number) {
158
+ return Math.max(min, Math.min(max, value))
159
+ }
@@ -0,0 +1,24 @@
1
+ import { createSignal } from "solid-js"
2
+ import type { VimLog } from "./log"
3
+
4
+ export type VimMode = "normal" | "insert"
5
+
6
+ export function createVimState(defaultMode: VimMode, log: VimLog = () => {}) {
7
+ const [mode, setMode] = createSignal<VimMode>(defaultMode)
8
+ const [pending, setPending] = createSignal("")
9
+
10
+ log("state.init", { mode: defaultMode })
11
+
12
+ return {
13
+ mode,
14
+ setMode(next: VimMode) {
15
+ if (mode() !== next) log("state.mode", { from: mode(), to: next })
16
+ setMode(next)
17
+ },
18
+ pending,
19
+ setPending(next: string) {
20
+ if (pending() !== next) log("state.pending", { from: pending(), to: next })
21
+ setPending(next)
22
+ },
23
+ }
24
+ }
@@ -0,0 +1,86 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { createSignal, onCleanup } from "solid-js"
3
+ import type { Accessor } from "solid-js"
4
+ import type { PromptContext } from "../../prompt/types"
5
+ import type { VimLog } from "./log"
6
+ import type { VimMode } from "./state"
7
+
8
+ const STATUS_SYNC_MS = 50
9
+
10
+ type VimStatusProps = {
11
+ mode: Accessor<VimMode>
12
+ pending: Accessor<string | undefined>
13
+ theme: PromptContext["api"]["theme"]["current"]
14
+ pendingDisplayDelay?: number
15
+ disabled?: boolean
16
+ log?: VimLog
17
+ requestRender?: () => void
18
+ }
19
+
20
+ export function VimStatus(props: VimStatusProps) {
21
+ const [mode, setMode] = createSignal(props.mode())
22
+ const [pending, setPending] = createSignal<string | undefined>()
23
+ let pendingTimer: ReturnType<typeof setTimeout> | undefined
24
+ let scheduledPending: string | undefined
25
+
26
+ const sync = () => {
27
+ const nextMode = props.mode()
28
+ const nextPending = props.pending()
29
+
30
+ if (mode() !== nextMode) {
31
+ props.log?.("status.sync", { fromMode: mode(), toMode: nextMode, fromPending: pending(), toPending: pending() })
32
+ setMode(nextMode)
33
+ props.requestRender?.()
34
+ }
35
+
36
+ syncPending(nextPending)
37
+ }
38
+
39
+ const timer = setInterval(sync, STATUS_SYNC_MS)
40
+ onCleanup(() => {
41
+ clearInterval(timer)
42
+ if (pendingTimer) clearTimeout(pendingTimer)
43
+ })
44
+
45
+ return (
46
+ <box paddingLeft={1} paddingRight={1} flexDirection="row">
47
+ {pending() ? <text fg={props.theme.info}>{pending()} </text> : undefined}
48
+ <text fg={props.disabled ? props.theme.textMuted : mode() === "normal" ? props.theme.warning : props.theme.success}>{mode() === "normal" ? "NORMAL" : "INSERT"}</text>
49
+ </box>
50
+ )
51
+
52
+ function syncPending(nextPending: string | undefined) {
53
+ if (!nextPending) {
54
+ if (pendingTimer) clearTimeout(pendingTimer)
55
+ pendingTimer = undefined
56
+ scheduledPending = undefined
57
+ setDisplayedPending(undefined)
58
+ return
59
+ }
60
+
61
+ if (pending() === nextPending) return
62
+ if (scheduledPending === nextPending) return
63
+
64
+ if (pendingTimer) clearTimeout(pendingTimer)
65
+ scheduledPending = nextPending
66
+ const delay = props.pendingDisplayDelay ?? 120
67
+ if (delay <= 0) {
68
+ scheduledPending = undefined
69
+ setDisplayedPending(nextPending)
70
+ return
71
+ }
72
+
73
+ pendingTimer = setTimeout(() => {
74
+ pendingTimer = undefined
75
+ scheduledPending = undefined
76
+ if (props.pending() === nextPending) setDisplayedPending(nextPending)
77
+ }, delay)
78
+ }
79
+
80
+ function setDisplayedPending(nextPending: string | undefined) {
81
+ if (pending() === nextPending) return
82
+ props.log?.("status.sync", { fromMode: mode(), toMode: mode(), fromPending: pending(), toPending: nextPending })
83
+ setPending(nextPending)
84
+ props.requestRender?.()
85
+ }
86
+ }