opencode-vim 0.0.22 → 0.0.24
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 +25 -63
- package/assets/vim-in-motion-dialogs.gif +0 -0
- package/dist/tui.js +3326 -0
- package/docs/configuration.md +50 -400
- package/docs/keymap-actions.md +60 -188
- package/docs/vim-behavior.md +93 -0
- package/package.json +6 -6
- package/src/modules/snippets/index.tsx +0 -17
- package/src/modules/snippets/loader.ts +0 -147
- package/src/modules/snippets/search.ts +0 -118
- package/src/modules/snippets/skill-loader.ts +0 -81
- package/src/modules/snippets/state.ts +0 -40
- package/src/modules/snippets/trigger.ts +0 -68
- package/src/modules/snippets/types.ts +0 -50
- package/src/modules/snippets/view.tsx +0 -674
- package/src/modules/vim/actions.ts +0 -85
- package/src/modules/vim/config.ts +0 -125
- package/src/modules/vim/edit.test.ts +0 -51
- package/src/modules/vim/edit.ts +0 -33
- package/src/modules/vim/graphemes.ts +0 -38
- package/src/modules/vim/index.tsx +0 -200
- package/src/modules/vim/keys.ts +0 -38
- package/src/modules/vim/log.ts +0 -37
- package/src/modules/vim/map.test.ts +0 -45
- package/src/modules/vim/map.ts +0 -198
- package/src/modules/vim/state.test.ts +0 -18
- package/src/modules/vim/state.tsx +0 -46
- package/src/modules/vim/view.tsx +0 -130
- package/src/modules/vim/vimee.test.ts +0 -211
- package/src/modules/vim/vimee.ts +0 -1146
- package/src/plugin.tsx +0 -116
- package/src/prompt/host.tsx +0 -38
- package/src/prompt/modules.ts +0 -16
- package/src/prompt/root.tsx +0 -69
- package/src/prompt/types.ts +0 -42
- package/src/update.ts +0 -120
- package/tui.tsx +0 -275
- package/view.tsx +0 -29
package/src/modules/vim/map.ts
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
import type { CursorPosition } from "@vimee/core"
|
|
2
|
-
import type { EditBufferLike } from "./actions"
|
|
3
|
-
import { createGraphemeCodec, type GraphemeCodec } from "./graphemes"
|
|
4
|
-
|
|
5
|
-
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
6
|
-
|
|
7
|
-
function graphemeWidth(value: string) {
|
|
8
|
-
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
|
|
9
|
-
// OpenTUI's edit buffer gives tabs a fixed two-column width by default.
|
|
10
|
-
if (value === "\t") return 2
|
|
11
|
-
return value === "\n" ? 1 : Bun.stringWidth(value)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function charToDisplay(text: string, charIndex: number): number {
|
|
15
|
-
let width = 0
|
|
16
|
-
const limit = Math.max(0, Math.min(charIndex, text.length))
|
|
17
|
-
for (const part of graphemes.segment(text)) {
|
|
18
|
-
if (part.index + part.segment.length > limit) break
|
|
19
|
-
width += graphemeWidth(part.segment)
|
|
20
|
-
}
|
|
21
|
-
return width
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function displayToChar(text: string, displayOffset: number): number {
|
|
25
|
-
let width = 0
|
|
26
|
-
for (const part of graphemes.segment(text)) {
|
|
27
|
-
const next = width + graphemeWidth(part.segment)
|
|
28
|
-
if (next > displayOffset) return part.index
|
|
29
|
-
width = next
|
|
30
|
-
}
|
|
31
|
-
return text.length
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function displayWidth(text: string): number {
|
|
35
|
-
return charToDisplay(text, text.length)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export type PromptMap = {
|
|
39
|
-
codec: GraphemeCodec
|
|
40
|
-
hostText: string
|
|
41
|
-
vimText: string
|
|
42
|
-
hostToVim: number[]
|
|
43
|
-
vimToHost: Array<number | undefined>
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function createPromptMap(hostText: string, input?: EditBufferLike, codec = createGraphemeCodec()): PromptMap {
|
|
47
|
-
return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [], codec)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
|
|
51
|
-
const prefix = commonPrefix(map.vimText, vimText)
|
|
52
|
-
const suffix = commonSuffix(map.vimText, vimText, prefix)
|
|
53
|
-
const inserted = vimText.slice(prefix, vimText.length - suffix)
|
|
54
|
-
const synthetic = new Set<number>()
|
|
55
|
-
let hostText = ""
|
|
56
|
-
|
|
57
|
-
for (let vimOffset = 0; vimOffset < vimText.length; vimOffset++) {
|
|
58
|
-
const oldOffset = previousOffset(map, vimOffset, vimText.length, prefix, suffix)
|
|
59
|
-
if (oldOffset !== undefined && map.vimToHost[oldOffset] === undefined && preserveSynthetic(vimOffset, prefix, suffix, vimText.length, inserted)) {
|
|
60
|
-
synthetic.add(vimOffset)
|
|
61
|
-
continue
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
hostText += map.codec.decode(vimText[vimOffset])
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return buildPromptMapFromSynthetic(hostText, vimText, synthetic, map.codec)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function preserveSynthetic(vimOffset: number, prefix: number, suffix: number, vimLength: number, inserted: string) {
|
|
71
|
-
if (!inserted.includes("\n")) return true
|
|
72
|
-
return vimOffset !== prefix - 1 && vimOffset !== vimLength - suffix
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function hostPosition(map: PromptMap, hostDisplayOffset: number): CursorPosition {
|
|
76
|
-
const charIdx = displayToChar(map.hostText, hostDisplayOffset)
|
|
77
|
-
return positionFromOffset(map.vimText, map.hostToVim[clamp(charIdx, 0, map.hostText.length)] ?? 0)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function hostOffset(map: PromptMap, position: CursorPosition, bias: "previous" | "next" = "next") {
|
|
81
|
-
const charIdx = hostOffsetFromVimOffset(map, offsetFromPosition(map.vimText, position), bias)
|
|
82
|
-
return charToDisplay(map.hostText, charIdx)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function buildPromptMap(hostText: string, wraps: number[], codec: GraphemeCodec): PromptMap {
|
|
86
|
-
const hostToVim: number[] = []
|
|
87
|
-
const vimToHost: Array<number | undefined> = []
|
|
88
|
-
const wrapOffsets = new Set(wraps.filter((offset) => offset > 0 && offset < hostText.length && hostText[offset - 1] !== "\n"))
|
|
89
|
-
let vimText = ""
|
|
90
|
-
let vimOffset = 0
|
|
91
|
-
|
|
92
|
-
for (const { index: hostOffset, segment } of graphemes.segment(hostText)) {
|
|
93
|
-
if (wrapOffsets.has(hostOffset)) {
|
|
94
|
-
vimText += "\n"
|
|
95
|
-
vimToHost[vimOffset] = undefined
|
|
96
|
-
vimOffset++
|
|
97
|
-
}
|
|
98
|
-
for (let index = hostOffset; index < hostOffset + segment.length; index++) hostToVim[index] = vimOffset
|
|
99
|
-
vimText += codec.encode(segment)
|
|
100
|
-
vimToHost[vimOffset] = hostOffset
|
|
101
|
-
vimOffset++
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
hostToVim[hostText.length] = vimOffset
|
|
105
|
-
return { hostText, vimText, hostToVim, vimToHost, codec }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number>, codec: GraphemeCodec): PromptMap {
|
|
109
|
-
const hostToVim: number[] = []
|
|
110
|
-
const vimToHost: Array<number | undefined> = []
|
|
111
|
-
let hostOffset = 0
|
|
112
|
-
|
|
113
|
-
for (let vimOffset = 0; vimOffset < vimText.length; vimOffset++) {
|
|
114
|
-
if (synthetic.has(vimOffset)) {
|
|
115
|
-
vimToHost[vimOffset] = undefined
|
|
116
|
-
continue
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
vimToHost[vimOffset] = hostOffset
|
|
120
|
-
const length = codec.decode(vimText[vimOffset]).length
|
|
121
|
-
for (let index = 0; index < length; index++) hostToVim[hostOffset++] = vimOffset
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
hostToVim[hostText.length] = vimText.length
|
|
125
|
-
return { hostText, vimText, hostToVim, vimToHost, codec }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, prefix: number, suffix: number) {
|
|
129
|
-
if (vimOffset < prefix) return vimOffset
|
|
130
|
-
if (vimOffset >= vimLength - suffix) return map.vimText.length - (vimLength - vimOffset)
|
|
131
|
-
return undefined
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function visualWrapOffsets(input: EditBufferLike, text: string) {
|
|
135
|
-
const wraps: number[] = []
|
|
136
|
-
const lines = input.editorView?.getLogicalLineInfo?.()
|
|
137
|
-
if (!lines) return wraps
|
|
138
|
-
let line = 1
|
|
139
|
-
let offset = 0
|
|
140
|
-
for (const part of graphemes.segment(text)) {
|
|
141
|
-
while (line < lines.lineStartCols.length && lines.lineStartCols[line] <= offset) {
|
|
142
|
-
if (lines.lineWraps[line] > 0) wraps.push(part.index)
|
|
143
|
-
line++
|
|
144
|
-
}
|
|
145
|
-
offset += graphemeWidth(part.segment)
|
|
146
|
-
}
|
|
147
|
-
return wraps
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function hostOffsetFromVimOffset(map: PromptMap, vimOffset: number, bias: "previous" | "next") {
|
|
151
|
-
const offset = clamp(vimOffset, 0, map.vimText.length)
|
|
152
|
-
if (offset === map.vimText.length) return map.hostText.length
|
|
153
|
-
|
|
154
|
-
const host = map.vimToHost[offset]
|
|
155
|
-
if (host !== undefined) return host
|
|
156
|
-
|
|
157
|
-
if (bias === "previous") {
|
|
158
|
-
for (let previous = offset - 1; previous >= 0; previous--) {
|
|
159
|
-
const previousHost = map.vimToHost[previous]
|
|
160
|
-
if (previousHost !== undefined) return previousHost
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
for (let next = offset + 1; next < map.vimToHost.length; next++) {
|
|
165
|
-
const nextHost = map.vimToHost[next]
|
|
166
|
-
if (nextHost !== undefined) return nextHost
|
|
167
|
-
}
|
|
168
|
-
return map.hostText.length
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function commonPrefix(left: string, right: string) {
|
|
172
|
-
let index = 0
|
|
173
|
-
while (index < left.length && index < right.length && left[index] === right[index]) index++
|
|
174
|
-
return index
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function commonSuffix(left: string, right: string, prefix: number) {
|
|
178
|
-
let length = 0
|
|
179
|
-
while (length + prefix < left.length && length + prefix < right.length && left[left.length - length - 1] === right[right.length - length - 1]) length++
|
|
180
|
-
return length
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function positionFromOffset(text: string, offset: number): CursorPosition {
|
|
184
|
-
const lines = text.slice(0, offset).split("\n")
|
|
185
|
-
return { line: lines.length - 1, col: lines[lines.length - 1]?.length ?? 0 }
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function offsetFromPosition(text: string, position: CursorPosition) {
|
|
189
|
-
const lines = text.split("\n")
|
|
190
|
-
const line = clamp(position.line, 0, Math.max(0, lines.length - 1))
|
|
191
|
-
let offset = 0
|
|
192
|
-
for (let index = 0; index < line; index++) offset += lines[index].length + 1
|
|
193
|
-
return offset + clamp(position.col, 0, lines[line]?.length ?? 0)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function clamp(value: number, min: number, max: number) {
|
|
197
|
-
return Math.max(min, Math.min(max, value))
|
|
198
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { createVimState } from "./state"
|
|
3
|
-
|
|
4
|
-
describe("vim state", () => {
|
|
5
|
-
test("isolates listener errors", () => {
|
|
6
|
-
const events: string[] = []
|
|
7
|
-
const state = createVimState("normal", (event) => events.push(event))
|
|
8
|
-
state.subscribe(() => {
|
|
9
|
-
throw new Error("stale renderable")
|
|
10
|
-
})
|
|
11
|
-
state.subscribe(() => events.push("listener.complete"))
|
|
12
|
-
|
|
13
|
-
expect(() => state.setMode("insert")).not.toThrow()
|
|
14
|
-
expect(state.mode()).toBe("insert")
|
|
15
|
-
expect(events).toContain("state.listener.error")
|
|
16
|
-
expect(events).toContain("listener.complete")
|
|
17
|
-
})
|
|
18
|
-
})
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
// Keep this module as TSX so OpenCode's plugin loader rewrites its Solid import
|
|
2
|
-
// to the host runtime shared by the footer and cursor effects.
|
|
3
|
-
import { createSignal } from "solid-js"
|
|
4
|
-
import type { VimLog } from "./log"
|
|
5
|
-
|
|
6
|
-
export type VimMode = "normal" | "insert" | "visual" | "visual-line"
|
|
7
|
-
export type VimStateListener = () => void
|
|
8
|
-
|
|
9
|
-
export function createVimState(defaultMode: VimMode, log: VimLog = () => {}) {
|
|
10
|
-
const [mode, setMode] = createSignal<VimMode>(defaultMode)
|
|
11
|
-
const [pending, setPending] = createSignal("")
|
|
12
|
-
const listeners = new Set<VimStateListener>()
|
|
13
|
-
|
|
14
|
-
log("state.init", { mode: defaultMode })
|
|
15
|
-
|
|
16
|
-
return {
|
|
17
|
-
mode,
|
|
18
|
-
setMode(next: VimMode) {
|
|
19
|
-
const changed = mode() !== next
|
|
20
|
-
if (changed) log("state.mode", { from: mode(), to: next })
|
|
21
|
-
setMode(next)
|
|
22
|
-
if (changed) notify()
|
|
23
|
-
},
|
|
24
|
-
pending,
|
|
25
|
-
setPending(next: string) {
|
|
26
|
-
const changed = pending() !== next
|
|
27
|
-
if (changed) log("state.pending", { from: pending(), to: next })
|
|
28
|
-
setPending(next)
|
|
29
|
-
if (changed) notify()
|
|
30
|
-
},
|
|
31
|
-
subscribe(listener: VimStateListener) {
|
|
32
|
-
listeners.add(listener)
|
|
33
|
-
return () => listeners.delete(listener)
|
|
34
|
-
},
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function notify() {
|
|
38
|
-
for (const listener of listeners) {
|
|
39
|
-
try {
|
|
40
|
-
listener()
|
|
41
|
-
} catch (error) {
|
|
42
|
-
log("state.listener.error", { error: error instanceof Error ? error.message : String(error) })
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
package/src/modules/vim/view.tsx
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import type { TextRenderable } from "@opentui/core"
|
|
3
|
-
import { onCleanup } from "solid-js"
|
|
4
|
-
import type { Accessor } from "solid-js"
|
|
5
|
-
import type { PromptContext } from "../../prompt/types"
|
|
6
|
-
import type { VimLog } from "./log"
|
|
7
|
-
import type { VimMode, VimStateListener } from "./state"
|
|
8
|
-
|
|
9
|
-
const STATUS_SYNC_MS = 50
|
|
10
|
-
|
|
11
|
-
type VimStatusProps = {
|
|
12
|
-
mode: Accessor<VimMode>
|
|
13
|
-
pending: Accessor<string | undefined>
|
|
14
|
-
subscribe: (listener: VimStateListener) => () => void
|
|
15
|
-
enabled: Accessor<boolean>
|
|
16
|
-
theme: PromptContext["api"]["theme"]["current"]
|
|
17
|
-
pendingDisplayDelay?: number
|
|
18
|
-
disabled?: boolean
|
|
19
|
-
log?: VimLog
|
|
20
|
-
requestRender?: () => void
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function VimStatus(props: VimStatusProps) {
|
|
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()
|
|
29
|
-
let pendingTimer: ReturnType<typeof setTimeout> | undefined
|
|
30
|
-
let scheduledPending: string | undefined
|
|
31
|
-
|
|
32
|
-
const sync = () => {
|
|
33
|
-
const nextMode = props.mode()
|
|
34
|
-
const nextPending = props.pending()
|
|
35
|
-
let changed = false
|
|
36
|
-
|
|
37
|
-
if (displayedMode !== nextMode) {
|
|
38
|
-
props.log?.("status.sync", { fromMode: displayedMode, toMode: nextMode, fromPending: displayedPending, toPending: displayedPending })
|
|
39
|
-
displayedMode = nextMode
|
|
40
|
-
changed = true
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const nextEnabled = props.enabled()
|
|
44
|
-
if (displayedEnabled !== nextEnabled) {
|
|
45
|
-
props.log?.("status.enabled", { from: displayedEnabled, to: nextEnabled })
|
|
46
|
-
displayedEnabled = nextEnabled
|
|
47
|
-
changed = true
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (changed) updateModeText()
|
|
51
|
-
syncPending(nextPending)
|
|
52
|
-
if (changed) props.requestRender?.()
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const unsubscribe = props.subscribe(sync)
|
|
56
|
-
const timer = setInterval(sync, STATUS_SYNC_MS)
|
|
57
|
-
onCleanup(() => {
|
|
58
|
-
unsubscribe()
|
|
59
|
-
clearInterval(timer)
|
|
60
|
-
if (pendingTimer) clearTimeout(pendingTimer)
|
|
61
|
-
modeText = undefined
|
|
62
|
-
pendingText = undefined
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
return (
|
|
66
|
-
<box paddingLeft={1} paddingRight={1} flexDirection="row">
|
|
67
|
-
<text ref={(ref: TextRenderable) => { pendingText = ref; updatePendingText() }} fg={props.theme.info}>{displayedPending ? `${displayedPending} ` : ""}</text>
|
|
68
|
-
<text ref={(ref: TextRenderable) => { modeText = ref; updateModeText() }} fg={modeColor(displayedMode, displayedEnabled, props.theme, props.disabled)}>{displayedEnabled ? modeLabel(displayedMode) : ""}</text>
|
|
69
|
-
</box>
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
function updateModeText() {
|
|
73
|
-
if (!modeText) return
|
|
74
|
-
modeText.content = displayedEnabled ? modeLabel(displayedMode) : ""
|
|
75
|
-
modeText.fg = modeColor(displayedMode, displayedEnabled, props.theme, props.disabled)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function updatePendingText() {
|
|
79
|
-
if (!pendingText) return
|
|
80
|
-
pendingText.content = displayedEnabled && displayedPending ? `${displayedPending} ` : ""
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function syncPending(nextPending: string | undefined) {
|
|
84
|
-
if (!nextPending) {
|
|
85
|
-
if (pendingTimer) clearTimeout(pendingTimer)
|
|
86
|
-
pendingTimer = undefined
|
|
87
|
-
scheduledPending = undefined
|
|
88
|
-
setDisplayedPending(undefined)
|
|
89
|
-
return
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (displayedPending === nextPending) return
|
|
93
|
-
if (scheduledPending === nextPending) return
|
|
94
|
-
|
|
95
|
-
if (pendingTimer) clearTimeout(pendingTimer)
|
|
96
|
-
scheduledPending = nextPending
|
|
97
|
-
const delay = props.pendingDisplayDelay ?? 120
|
|
98
|
-
if (delay <= 0) {
|
|
99
|
-
scheduledPending = undefined
|
|
100
|
-
setDisplayedPending(nextPending)
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
pendingTimer = setTimeout(() => {
|
|
105
|
-
pendingTimer = undefined
|
|
106
|
-
scheduledPending = undefined
|
|
107
|
-
if (props.pending() === nextPending) setDisplayedPending(nextPending)
|
|
108
|
-
}, delay)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function setDisplayedPending(nextPending: string | undefined) {
|
|
112
|
-
if (displayedPending === nextPending) return
|
|
113
|
-
props.log?.("status.sync", { fromMode: displayedMode, toMode: displayedMode, fromPending: displayedPending, toPending: nextPending })
|
|
114
|
-
displayedPending = nextPending
|
|
115
|
-
updatePendingText()
|
|
116
|
-
props.requestRender?.()
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function modeColor(mode: VimMode, enabled: boolean, theme: PromptContext["api"]["theme"]["current"], disabled?: boolean) {
|
|
121
|
-
if (!enabled) return undefined
|
|
122
|
-
if (disabled) return theme.textMuted
|
|
123
|
-
return mode === "insert" ? theme.success : theme.warning
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function modeLabel(mode: VimMode) {
|
|
127
|
-
if (mode === "visual") return "VISUAL"
|
|
128
|
-
if (mode === "visual-line") return "VISUAL LINE"
|
|
129
|
-
return mode === "normal" ? "NORMAL" : "INSERT"
|
|
130
|
-
}
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import type { KeyEvent } from "@opentui/core"
|
|
3
|
-
import type { PromptContext } from "./actions"
|
|
4
|
-
import { createVimConfig } from "./config"
|
|
5
|
-
import type { VimLog } from "./log"
|
|
6
|
-
import { createVimState, type VimMode } from "./state"
|
|
7
|
-
import { createVimeeAdapter } from "./vimee"
|
|
8
|
-
|
|
9
|
-
describe("vim command keymaps", () => {
|
|
10
|
-
test("does not restore stale cursor state after a normal command", () => {
|
|
11
|
-
const fixture = createFixture("normal", "command:test.run")
|
|
12
|
-
fixture.dispatch = () => {
|
|
13
|
-
fixture.input.plainText = "updated"
|
|
14
|
-
fixture.input.cursorOffset = 7
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
expect(fixture.handle()).toBe(true)
|
|
18
|
-
expect(fixture.commands).toEqual(["test.run"])
|
|
19
|
-
expect(fixture.input.cursorOffset).toBe(7)
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
test("uses the host history boundary and restores display width", () => {
|
|
23
|
-
const fixture = createFixture("normal", "command:prompt.history.next", "中")
|
|
24
|
-
let boundary = -1
|
|
25
|
-
fixture.dispatch = () => {
|
|
26
|
-
boundary = fixture.input.cursorOffset
|
|
27
|
-
fixture.input.plainText = "中中"
|
|
28
|
-
fixture.input.cursorOffset = 2
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
expect(fixture.handle()).toBe(true)
|
|
32
|
-
expect(boundary).toBe(1)
|
|
33
|
-
expect(fixture.input.cursorOffset).toBe(4)
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
test("dispatches commands in insert mode", () => {
|
|
37
|
-
const fixture = createFixture("insert", "command:test.run")
|
|
38
|
-
|
|
39
|
-
expect(fixture.handle()).toBe(true)
|
|
40
|
-
expect(fixture.commands).toEqual(["test.run"])
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
test("rejects an empty command name", () => {
|
|
44
|
-
const logs: Array<[string, unknown]> = []
|
|
45
|
-
createFixture("normal", "command: ", "", (event, data) => logs.push([event, data]))
|
|
46
|
-
|
|
47
|
-
expect(logs.some(([event]) => event === "vimee.keymap.invalid")).toBe(true)
|
|
48
|
-
})
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
describe("vim enter keymaps", () => {
|
|
52
|
-
test("submits unmapped Enter in normal mode without moving the cursor", () => {
|
|
53
|
-
const fixture = createFixture("normal", undefined, "中")
|
|
54
|
-
fixture.input.cursorOffset = 2
|
|
55
|
-
|
|
56
|
-
expect(fixture.handle("<CR>")).toBe(true)
|
|
57
|
-
expect(fixture.submissions).toHaveLength(1)
|
|
58
|
-
expect(fixture.input.cursorOffset).toBe(2)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
test("passes unmapped Enter through in insert mode", () => {
|
|
62
|
-
const fixture = createFixture("insert", undefined)
|
|
63
|
-
|
|
64
|
-
expect(fixture.handle("<CR>")).toBe(false)
|
|
65
|
-
expect(fixture.submissions).toHaveLength(0)
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test("does not submit a matched mapping with no immediate actions", () => {
|
|
69
|
-
const fixture = createFixture("normal", "d", "text", () => {}, "<CR>")
|
|
70
|
-
|
|
71
|
-
expect(fixture.handle("<CR>")).toBe(true)
|
|
72
|
-
expect(fixture.submissions).toHaveLength(0)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
test("runs an Enter command mapping", () => {
|
|
76
|
-
const fixture = createFixture("normal", "command:test.run", "", () => {}, "<CR>")
|
|
77
|
-
|
|
78
|
-
expect(fixture.handle("<CR>")).toBe(true)
|
|
79
|
-
expect(fixture.commands).toEqual(["test.run"])
|
|
80
|
-
expect(fixture.submissions).toHaveLength(0)
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
test("applies mode and submit mappings", () => {
|
|
84
|
-
const normal = createFixture("normal", "insert", "", () => {}, "<CR>")
|
|
85
|
-
const insert = createFixture("insert", "submit", "", () => {}, "<CR>")
|
|
86
|
-
|
|
87
|
-
expect(normal.handle("<CR>")).toBe(true)
|
|
88
|
-
expect(normal.handle("a")).toBe(false)
|
|
89
|
-
expect(insert.handle("<CR>")).toBe(true)
|
|
90
|
-
expect(insert.submissions).toHaveLength(1)
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
test("allows Enter to finish a multi-key mapping", () => {
|
|
94
|
-
const fixture = createFixture("normal", "command:test.run", "", () => {}, "g<CR>")
|
|
95
|
-
|
|
96
|
-
expect(fixture.handle("g")).toBe(true)
|
|
97
|
-
expect(fixture.handle("<CR>")).toBe(true)
|
|
98
|
-
expect(fixture.commands).toEqual(["test.run"])
|
|
99
|
-
expect(fixture.submissions).toHaveLength(0)
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
test("rejects mappings that defer Enter", () => {
|
|
103
|
-
const logs: Array<[string, unknown]> = []
|
|
104
|
-
const fixture = createFixture("normal", "command:test.run", "", (event, data) => logs.push([event, data]), "<CR>x")
|
|
105
|
-
|
|
106
|
-
expect(fixture.handle("<CR>")).toBe(true)
|
|
107
|
-
expect(fixture.submissions).toHaveLength(1)
|
|
108
|
-
expect(logs.some(([event]) => event === "vimee.keymap.invalid")).toBe(true)
|
|
109
|
-
})
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
describe("vim prompt history", () => {
|
|
113
|
-
test("starts from an empty prompt and continues through history", () => {
|
|
114
|
-
const fixture = createFixture("normal", undefined, "")
|
|
115
|
-
const entries = ["newest", "older", ""]
|
|
116
|
-
fixture.dispatch = (command) => {
|
|
117
|
-
fixture.input.plainText = entries.shift() ?? ""
|
|
118
|
-
fixture.input.cursorOffset = command === "prompt.history.previous" ? 0 : fixture.input.plainText.length
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
expect(fixture.handle("k")).toBe(true)
|
|
122
|
-
expect(fixture.handle("k")).toBe(true)
|
|
123
|
-
expect(fixture.handle("j")).toBe(true)
|
|
124
|
-
expect(fixture.commands).toEqual([
|
|
125
|
-
"prompt.history.previous",
|
|
126
|
-
"prompt.history.previous",
|
|
127
|
-
"prompt.history.next",
|
|
128
|
-
])
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
test("keeps normal movement for a nonempty prompt", () => {
|
|
132
|
-
const fixture = createFixture("normal", undefined, "text")
|
|
133
|
-
|
|
134
|
-
expect(fixture.handle("k")).toBe(true)
|
|
135
|
-
expect(fixture.handle("j")).toBe(true)
|
|
136
|
-
expect(fixture.commands).toEqual([])
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
test("stops history navigation after another normal key", () => {
|
|
140
|
-
const fixture = createFixture("normal", undefined, "")
|
|
141
|
-
fixture.dispatch = () => {
|
|
142
|
-
fixture.input.plainText = "recalled"
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
expect(fixture.handle("k")).toBe(true)
|
|
146
|
-
expect(fixture.handle("h")).toBe(true)
|
|
147
|
-
expect(fixture.handle("k")).toBe(true)
|
|
148
|
-
expect(fixture.commands).toEqual(["prompt.history.previous"])
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
test("prefers configured keymaps", () => {
|
|
152
|
-
const fixture = createFixture("normal", "command:test.run", "", () => {}, "k")
|
|
153
|
-
|
|
154
|
-
expect(fixture.handle()).toBe(true)
|
|
155
|
-
expect(fixture.commands).toEqual(["test.run"])
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
test("completes a pending keymap before history navigation", () => {
|
|
159
|
-
const fixture = createFixture("normal", "command:test.run", "", () => {}, "gk")
|
|
160
|
-
|
|
161
|
-
expect(fixture.handle("g")).toBe(true)
|
|
162
|
-
expect(fixture.handle("k")).toBe(true)
|
|
163
|
-
expect(fixture.commands).toEqual(["test.run"])
|
|
164
|
-
})
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
function createFixture(mode: VimMode, action: string | undefined, text = "text", log: VimLog = () => {}, mappedKey = "Q") {
|
|
168
|
-
const input = {
|
|
169
|
-
plainText: text,
|
|
170
|
-
cursorOffset: 0,
|
|
171
|
-
visualCursor: { visualRow: 0, visualCol: 0, offset: 0 },
|
|
172
|
-
moveCursorLeft: () => false,
|
|
173
|
-
}
|
|
174
|
-
const commands: string[] = []
|
|
175
|
-
const submissions: true[] = []
|
|
176
|
-
const prompt = {
|
|
177
|
-
current: { input: text, mode: "normal", parts: [] },
|
|
178
|
-
set() {},
|
|
179
|
-
submit() {
|
|
180
|
-
submissions.push(true)
|
|
181
|
-
},
|
|
182
|
-
blur() {},
|
|
183
|
-
}
|
|
184
|
-
const fixture = {
|
|
185
|
-
input,
|
|
186
|
-
commands,
|
|
187
|
-
submissions,
|
|
188
|
-
dispatch: (_command: string) => {},
|
|
189
|
-
handle: (key = mappedKey) => adapter.handle({ name: key } as KeyEvent, key, ctx),
|
|
190
|
-
}
|
|
191
|
-
const ctx = {
|
|
192
|
-
api: {
|
|
193
|
-
renderer: { currentFocusedRenderable: input },
|
|
194
|
-
keymap: {
|
|
195
|
-
dispatchCommand(command: string) {
|
|
196
|
-
commands.push(command)
|
|
197
|
-
fixture.dispatch(command)
|
|
198
|
-
return { ok: true as const }
|
|
199
|
-
},
|
|
200
|
-
},
|
|
201
|
-
},
|
|
202
|
-
prompt: () => prompt,
|
|
203
|
-
requestRender() {},
|
|
204
|
-
} as unknown as PromptContext
|
|
205
|
-
const config = createVimConfig({
|
|
206
|
-
defaultMode: mode,
|
|
207
|
-
keymaps: action ? { [mode]: { [mappedKey]: action } } : undefined,
|
|
208
|
-
})
|
|
209
|
-
const adapter = createVimeeAdapter(createVimState(mode), config, log)
|
|
210
|
-
return fixture
|
|
211
|
-
}
|