opencode-vim 0.0.19 → 0.0.21

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.
@@ -7,6 +7,7 @@ import type { SnippetController } from "../snippets/types"
7
7
  import { applyVimCursorStyle, focusedInput } from "./actions"
8
8
  import type { VimConfig } from "./config"
9
9
  import { createVimConfig } from "./config"
10
+ import { displayToChar } from "./map"
10
11
  import { keyNotation } from "./keys"
11
12
  import { createVimLog } from "./log"
12
13
  import type { VimLog } from "./log"
@@ -38,17 +39,9 @@ export function createVimModule(options?: unknown, enabled: Accessor<boolean> =
38
39
  function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType<typeof createVimState>; snippets?: SnippetController; enabled: Accessor<boolean>; log: VimLog }) {
39
40
  let cursorStyleMode = ""
40
41
  const vimee = createVimeeAdapter(props.state, props.config, props.log)
41
- const preparedEvents = new WeakSet<KeyEvent>()
42
42
  props.log("keyboard.mount", { kind: props.ctx.kind })
43
43
 
44
44
  const cursorStyleTimer = setInterval(syncCursorStyle, 50)
45
- const offKeyIntercept = props.ctx.api.keymap.intercept("key", ({ event }) => {
46
- if (!canHandleKeys(props)) return
47
- const key = keyNotation(event)
48
- if (!key || !passThroughKey(event, key, props.state.mode())) return
49
- if (preparePassThroughKey(props.ctx, key, props.state.mode())) preparedEvents.add(event)
50
- }, { priority: 100 })
51
-
52
45
  const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => {
53
46
  props.log("keyboard.event", {
54
47
  name: event.name,
@@ -72,10 +65,7 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
72
65
  return
73
66
  }
74
67
 
75
- if (passThroughKey(event, key, props.state.mode())) {
76
- if (!preparedEvents.delete(event)) preparePassThroughKey(props.ctx, key, props.state.mode())
77
- return
78
- }
68
+ if (passThroughKey(event, key, props.state.mode())) return
79
69
 
80
70
  if (key === "<Esc>" && props.state.mode() === "normal" && !props.state.pending()) {
81
71
  props.log("keyboard.fallthrough", { key, mode: props.state.mode() })
@@ -88,10 +78,17 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
88
78
  return
89
79
  }
90
80
 
91
- const consumed = vimee.handle(event, key, props.ctx)
81
+ const modeBefore = props.state.mode()
82
+ let consumed = false
83
+ try {
84
+ consumed = vimee.handle(event, key, props.ctx)
85
+ } finally {
86
+ if (consumed || props.state.mode() !== modeBefore) {
87
+ event.preventDefault()
88
+ event.stopPropagation()
89
+ }
90
+ }
92
91
  if (consumed) {
93
- event.preventDefault()
94
- event.stopPropagation()
95
92
  syncCursorStyle(true)
96
93
  props.ctx.requestRender()
97
94
  }
@@ -99,7 +96,6 @@ function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: Retu
99
96
 
100
97
  onCleanup(() => {
101
98
  props.log("keyboard.cleanup", { kind: props.ctx.kind })
102
- offKeyIntercept()
103
99
  offKeyboard()
104
100
  vimee.cleanup()
105
101
  clearInterval(cursorStyleTimer)
@@ -128,15 +124,7 @@ function readablePending(sequence: string) {
128
124
 
129
125
  function passThroughKey(event: KeyEvent, key: string, mode: string) {
130
126
  if (mode !== "normal") return false
131
- return event.super === true || isArrowKey(key) || key === "<CR>" || key === "<C-c>"
132
- }
133
-
134
- function preparePassThroughKey(ctx: PromptContext, key: string, mode: string) {
135
- if (mode !== "normal" || key !== "<CR>") return false
136
- const input = focusedInput(ctx)
137
- if (!input?.plainText || input.cursorOffset === undefined) return false
138
- input.cursorOffset = Math.min(input.cursorOffset + 1, input.plainText.length)
139
- return true
127
+ return event.super === true || isArrowKey(key) || key === "<C-c>"
140
128
  }
141
129
 
142
130
  function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string, snippets?: SnippetController) {
@@ -178,8 +166,8 @@ function isPromptFocused(ctx: PromptContext) {
178
166
  function isNativeCompletionToken(ctx: PromptContext) {
179
167
  const text = ctx.prompt()?.current.input ?? focusedInput(ctx)?.plainText ?? ""
180
168
  const input = focusedInput(ctx)
181
- const offset = Math.max(0, input?.cursorOffset ?? text.length)
182
- const beforeCursor = text.slice(0, Math.min(offset + 1, text.length))
169
+ const charIdx = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0))
170
+ const beforeCursor = text.slice(0, Math.min(charIdx + 1, text.length))
183
171
  return /^\/\S*$/.test(beforeCursor) || /(?:^|\s)@\S*$/.test(beforeCursor)
184
172
  }
185
173
 
@@ -1,5 +1,7 @@
1
1
  import type { KeyEvent } from "@opentui/core"
2
2
 
3
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
4
+
3
5
  const NAMED_KEYS: Record<string, string> = {
4
6
  escape: "<Esc>",
5
7
  esc: "<Esc>",
@@ -15,6 +17,8 @@ const NAMED_KEYS: Record<string, string> = {
15
17
  right: "<Right>",
16
18
  home: "<Home>",
17
19
  end: "<End>",
20
+ pageup: "<PageUp>",
21
+ pagedown: "<PageDown>",
18
22
  }
19
23
 
20
24
  export function keyNotation(event: KeyEvent) {
@@ -23,7 +27,7 @@ export function keyNotation(event: KeyEvent) {
23
27
 
24
28
  if (event.ctrl) return `<C-${ctrlKey(name)}>`
25
29
  if (event.meta) return `<M-${name}>`
26
- if (name.length === 1) return event.shift ? name.toUpperCase() : name
30
+ if ([...graphemes.segment(name)].length === 1) return event.shift ? name.toUpperCase() : name
27
31
  return NAMED_KEYS[name] ?? `<${name}>`
28
32
  }
29
33
 
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { charToDisplay, displayToChar, displayWidth } from "./map"
3
+
4
+ describe("vim display offsets", () => {
5
+ test("maps ASCII and CJK offsets", () => {
6
+ const text = "a中b"
7
+
8
+ expect(displayWidth(text)).toBe(4)
9
+ expect(charToDisplay(text, 2)).toBe(3)
10
+ expect(displayToChar(text, 2)).toBe(1)
11
+ expect(displayToChar(text, 3)).toBe(2)
12
+ })
13
+
14
+ test("does not split supplementary characters", () => {
15
+ const text = "𠀀x"
16
+
17
+ expect(displayWidth(text)).toBe(3)
18
+ expect(charToDisplay(text, 1)).toBe(0)
19
+ expect(charToDisplay(text, 2)).toBe(2)
20
+ expect(displayToChar(text, 1)).toBe(0)
21
+ expect(displayToChar(text, 2)).toBe(2)
22
+ })
23
+
24
+ test("does not split grapheme clusters", () => {
25
+ const emoji = "👨‍👩‍👧‍👦"
26
+ const accent = "e\u0301"
27
+
28
+ expect(displayWidth(emoji)).toBe(2)
29
+ expect(charToDisplay(emoji, 1)).toBe(0)
30
+ expect(displayToChar(emoji, 1)).toBe(0)
31
+ expect(displayToChar(emoji, 2)).toBe(emoji.length)
32
+ expect(displayWidth(accent)).toBe(1)
33
+ expect(charToDisplay(accent, 1)).toBe(0)
34
+ expect(displayToChar(accent, 1)).toBe(accent.length)
35
+ })
36
+
37
+ test("counts newlines as textarea offsets", () => {
38
+ const text = "中\nx"
39
+
40
+ expect(displayWidth(text)).toBe(4)
41
+ expect(displayToChar(text, 2)).toBe(1)
42
+ expect(displayToChar(text, 3)).toBe(2)
43
+ expect(charToDisplay(text, 2)).toBe(3)
44
+ })
45
+ })
@@ -1,15 +1,50 @@
1
1
  import type { CursorPosition } from "@vimee/core"
2
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
+ }
3
37
 
4
38
  export type PromptMap = {
39
+ codec: GraphemeCodec
5
40
  hostText: string
6
41
  vimText: string
7
42
  hostToVim: number[]
8
43
  vimToHost: Array<number | undefined>
9
44
  }
10
45
 
11
- export function createPromptMap(hostText: string, input?: EditBufferLike): PromptMap {
12
- return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [])
46
+ export function createPromptMap(hostText: string, input?: EditBufferLike, codec = createGraphemeCodec()): PromptMap {
47
+ return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [], codec)
13
48
  }
14
49
 
15
50
  export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
@@ -26,10 +61,10 @@ export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
26
61
  continue
27
62
  }
28
63
 
29
- hostText += vimText[vimOffset]
64
+ hostText += map.codec.decode(vimText[vimOffset])
30
65
  }
31
66
 
32
- return buildPromptMapFromSynthetic(hostText, vimText, synthetic)
67
+ return buildPromptMapFromSynthetic(hostText, vimText, synthetic, map.codec)
33
68
  }
34
69
 
35
70
  function preserveSynthetic(vimOffset: number, prefix: number, suffix: number, vimLength: number, inserted: string) {
@@ -37,38 +72,40 @@ function preserveSynthetic(vimOffset: number, prefix: number, suffix: number, vi
37
72
  return vimOffset !== prefix - 1 && vimOffset !== vimLength - suffix
38
73
  }
39
74
 
40
- export function hostPosition(map: PromptMap, hostOffset: number): CursorPosition {
41
- return positionFromOffset(map.vimText, map.hostToVim[clamp(hostOffset, 0, map.hostText.length)] ?? 0)
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)
42
78
  }
43
79
 
44
80
  export function hostOffset(map: PromptMap, position: CursorPosition, bias: "previous" | "next" = "next") {
45
- return hostOffsetFromVimOffset(map, offsetFromPosition(map.vimText, position), bias)
81
+ const charIdx = hostOffsetFromVimOffset(map, offsetFromPosition(map.vimText, position), bias)
82
+ return charToDisplay(map.hostText, charIdx)
46
83
  }
47
84
 
48
- function buildPromptMap(hostText: string, wraps: number[]): PromptMap {
85
+ function buildPromptMap(hostText: string, wraps: number[], codec: GraphemeCodec): PromptMap {
49
86
  const hostToVim: number[] = []
50
87
  const vimToHost: Array<number | undefined> = []
51
88
  const wrapOffsets = new Set(wraps.filter((offset) => offset > 0 && offset < hostText.length && hostText[offset - 1] !== "\n"))
52
89
  let vimText = ""
53
90
  let vimOffset = 0
54
91
 
55
- for (let hostOffset = 0; hostOffset < hostText.length; hostOffset++) {
92
+ for (const { index: hostOffset, segment } of graphemes.segment(hostText)) {
56
93
  if (wrapOffsets.has(hostOffset)) {
57
94
  vimText += "\n"
58
95
  vimToHost[vimOffset] = undefined
59
96
  vimOffset++
60
97
  }
61
- hostToVim[hostOffset] = vimOffset
62
- vimText += hostText[hostOffset]
98
+ for (let index = hostOffset; index < hostOffset + segment.length; index++) hostToVim[index] = vimOffset
99
+ vimText += codec.encode(segment)
63
100
  vimToHost[vimOffset] = hostOffset
64
101
  vimOffset++
65
102
  }
66
103
 
67
104
  hostToVim[hostText.length] = vimOffset
68
- return { hostText, vimText, hostToVim, vimToHost }
105
+ return { hostText, vimText, hostToVim, vimToHost, codec }
69
106
  }
70
107
 
71
- function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number>): PromptMap {
108
+ function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number>, codec: GraphemeCodec): PromptMap {
72
109
  const hostToVim: number[] = []
73
110
  const vimToHost: Array<number | undefined> = []
74
111
  let hostOffset = 0
@@ -79,13 +116,13 @@ function buildPromptMapFromSynthetic(hostText: string, vimText: string, syntheti
79
116
  continue
80
117
  }
81
118
 
82
- hostToVim[hostOffset] = vimOffset
83
119
  vimToHost[vimOffset] = hostOffset
84
- hostOffset++
120
+ const length = codec.decode(vimText[vimOffset]).length
121
+ for (let index = 0; index < length; index++) hostToVim[hostOffset++] = vimOffset
85
122
  }
86
123
 
87
124
  hostToVim[hostText.length] = vimText.length
88
- return { hostText, vimText, hostToVim, vimToHost }
125
+ return { hostText, vimText, hostToVim, vimToHost, codec }
89
126
  }
90
127
 
91
128
  function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, prefix: number, suffix: number) {
@@ -95,22 +132,18 @@ function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, pr
95
132
  }
96
133
 
97
134
  function visualWrapOffsets(input: EditBufferLike, text: string) {
98
- const original = input.cursorOffset
99
135
  const wraps: number[] = []
100
- let previousRow: number | undefined
101
-
102
- for (let offset = 0; offset <= text.length; offset++) {
103
- input.cursorOffset = offset
104
- const row = input.visualCursor?.visualRow
105
- if (row === undefined) {
106
- wraps.length = 0
107
- break
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++
108
144
  }
109
- if (previousRow !== undefined && row > previousRow && text[offset - 1] !== "\n") wraps.push(offset)
110
- previousRow = row
145
+ offset += graphemeWidth(part.segment)
111
146
  }
112
-
113
- input.cursorOffset = original
114
147
  return wraps
115
148
  }
116
149
 
@@ -0,0 +1,18 @@
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
+ })
@@ -33,6 +33,12 @@ export function createVimState(defaultMode: VimMode, log: VimLog = () => {}) {
33
33
  }
34
34
 
35
35
  function notify() {
36
- for (const listener of listeners) listener()
36
+ for (const listener of listeners) {
37
+ try {
38
+ listener()
39
+ } catch (error) {
40
+ log("state.listener.error", { error: error instanceof Error ? error.message : String(error) })
41
+ }
42
+ }
37
43
  }
38
44
  }
@@ -58,6 +58,8 @@ export function VimStatus(props: VimStatusProps) {
58
58
  unsubscribe()
59
59
  clearInterval(timer)
60
60
  if (pendingTimer) clearTimeout(pendingTimer)
61
+ modeText = undefined
62
+ pendingText = undefined
61
63
  })
62
64
 
63
65
  return (
@@ -0,0 +1,211 @@
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
+ }