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.
Files changed (38) hide show
  1. package/README.md +25 -63
  2. package/assets/vim-in-motion-dialogs.gif +0 -0
  3. package/dist/tui.js +3326 -0
  4. package/docs/configuration.md +50 -400
  5. package/docs/keymap-actions.md +60 -188
  6. package/docs/vim-behavior.md +93 -0
  7. package/package.json +6 -6
  8. package/src/modules/snippets/index.tsx +0 -17
  9. package/src/modules/snippets/loader.ts +0 -147
  10. package/src/modules/snippets/search.ts +0 -118
  11. package/src/modules/snippets/skill-loader.ts +0 -81
  12. package/src/modules/snippets/state.ts +0 -40
  13. package/src/modules/snippets/trigger.ts +0 -68
  14. package/src/modules/snippets/types.ts +0 -50
  15. package/src/modules/snippets/view.tsx +0 -674
  16. package/src/modules/vim/actions.ts +0 -85
  17. package/src/modules/vim/config.ts +0 -125
  18. package/src/modules/vim/edit.test.ts +0 -51
  19. package/src/modules/vim/edit.ts +0 -33
  20. package/src/modules/vim/graphemes.ts +0 -38
  21. package/src/modules/vim/index.tsx +0 -200
  22. package/src/modules/vim/keys.ts +0 -38
  23. package/src/modules/vim/log.ts +0 -37
  24. package/src/modules/vim/map.test.ts +0 -45
  25. package/src/modules/vim/map.ts +0 -198
  26. package/src/modules/vim/state.test.ts +0 -18
  27. package/src/modules/vim/state.tsx +0 -46
  28. package/src/modules/vim/view.tsx +0 -130
  29. package/src/modules/vim/vimee.test.ts +0 -211
  30. package/src/modules/vim/vimee.ts +0 -1146
  31. package/src/plugin.tsx +0 -116
  32. package/src/prompt/host.tsx +0 -38
  33. package/src/prompt/modules.ts +0 -16
  34. package/src/prompt/root.tsx +0 -69
  35. package/src/prompt/types.ts +0 -42
  36. package/src/update.ts +0 -120
  37. package/tui.tsx +0 -275
  38. package/view.tsx +0 -29
@@ -1,85 +0,0 @@
1
- import type { CursorStyleOptions, LineInfo, RGBA } from "@opentui/core"
2
- import type { VimCursorStyle } from "./config"
3
-
4
- type PromptInfo = {
5
- input: string
6
- mode: string
7
- parts: unknown[]
8
- }
9
-
10
- type PromptRef = {
11
- current: PromptInfo
12
- set: (value: PromptInfo) => void
13
- submit: () => void
14
- blur: () => void
15
- }
16
-
17
- export type PromptContext = {
18
- api: {
19
- renderer: { currentFocusedRenderable?: unknown }
20
- keymap: { dispatchCommand: (command: string) => { ok: boolean } }
21
- theme: { current: { warning: RGBA; info: RGBA; background: RGBA } }
22
- }
23
- prompt: () => PromptRef | undefined
24
- requestRender: () => void
25
- }
26
-
27
- export type EditBufferLike = {
28
- isDestroyed?: boolean
29
- width?: number
30
- wrapMode?: string
31
- cursorOffset?: number
32
- plainText?: string
33
- visualCursor?: VisualCursorLike
34
- editorView?: { getLogicalLineInfo?: () => LineInfo; getVisualEOL?: () => VisualCursorLike | undefined; setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void; resetSelection?: () => void }
35
- cursorStyle?: CursorStyleOptions
36
- selectionBg?: RGBA
37
- selectionFg?: RGBA
38
- moveCursorLeft?: () => boolean
39
- moveCursorRight?: () => boolean
40
- moveCursorUp?: () => boolean
41
- moveCursorDown?: () => boolean
42
- setSelection?: (start: number, end: number) => void
43
- insertText?: (text: string) => void
44
- setSelectionInclusive?: (start: number, end: number) => void
45
- clearSelection?: () => void
46
- gotoVisualLineEnd?: () => boolean
47
- gotoLineEnd?: () => void
48
- }
49
-
50
- export type VisualCursorLike = {
51
- visualRow?: number
52
- visualCol?: number
53
- logicalRow?: number
54
- logicalCol?: number
55
- offset?: number
56
- }
57
-
58
- export function applyVimCursorStyle(ctx: PromptContext, style: VimCursorStyle) {
59
- const input = focusedInput(ctx)
60
- if (!input) return false
61
- input.cursorStyle = style
62
- return true
63
- }
64
-
65
- export function focusedInput(ctx: PromptContext): EditBufferLike | undefined {
66
- const focused = ctx.api.renderer.currentFocusedRenderable as EditBufferLike | null | undefined
67
- if (!focused || !hasEditBufferMethods(focused)) return undefined
68
- return focused
69
- }
70
-
71
- export function setInput(ref: PromptRef, input: string) {
72
- ref.set(toPromptInfo(ref, input))
73
- }
74
-
75
- function hasEditBufferMethods(input: EditBufferLike) {
76
- return typeof input.moveCursorLeft === "function" || typeof input.moveCursorRight === "function" || typeof input.moveCursorUp === "function" || typeof input.moveCursorDown === "function" || typeof input.gotoLineEnd === "function"
77
- }
78
-
79
- function toPromptInfo(ref: PromptRef, input: string): PromptInfo {
80
- return {
81
- input,
82
- mode: ref.current.mode,
83
- parts: [...ref.current.parts],
84
- }
85
- }
@@ -1,125 +0,0 @@
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
- visual: { style: "block", blinking: true },
35
- "visual-line": { style: "block", blinking: true },
36
- }
37
-
38
- export function createVimConfig(options: unknown): VimConfig {
39
- const input = readOptions(options)
40
- return {
41
- defaultMode: input.defaultMode ?? "insert",
42
- keymapTimeout: Math.max(0, input.keymapTimeout ?? 500),
43
- pendingDisplayDelay: Math.max(0, input.pendingDisplayDelay ?? 120),
44
- cursorStyles: {
45
- insert: { ...DEFAULT_CURSOR_STYLES.insert, ...input.cursorStyles?.insert },
46
- normal: { ...DEFAULT_CURSOR_STYLES.normal, ...input.cursorStyles?.normal },
47
- visual: { ...DEFAULT_CURSOR_STYLES.visual, ...input.cursorStyles?.visual },
48
- "visual-line": { ...DEFAULT_CURSOR_STYLES["visual-line"], ...input.cursorStyles?.["visual-line"] },
49
- },
50
- debug: input.debug ?? process.env.VIM_PROMPT_DEBUG === "1",
51
- debugPath: input.debugPath,
52
- keymaps: input.keymaps ?? {},
53
- }
54
- }
55
-
56
- function readOptions(options: unknown): VimOptions {
57
- if (!options || typeof options !== "object") return {}
58
- const raw = "vim" in options ? (options as { vim?: unknown }).vim : options
59
- if (!raw || typeof raw !== "object") return {}
60
-
61
- const source = raw as Record<string, unknown>
62
- return {
63
- defaultMode: isMode(source.defaultMode) ? source.defaultMode : undefined,
64
- keymapTimeout: readNumber(source.keymapTimeout),
65
- pendingDisplayDelay: typeof source.pendingDisplayDelay === "number" ? source.pendingDisplayDelay : undefined,
66
- cursorStyles: readCursorStyles(source.cursorStyles),
67
- debug: typeof source.debug === "boolean" ? source.debug : undefined,
68
- debugPath: typeof source.debugPath === "string" ? source.debugPath : undefined,
69
- keymaps: readKeymaps(source.keymaps),
70
- }
71
- }
72
-
73
- function readKeymaps(input: unknown): VimKeymaps | undefined {
74
- if (!input || typeof input !== "object") return undefined
75
- const source = input as Record<string, unknown>
76
- const keymaps: VimKeymaps = {}
77
-
78
- for (const mode of ["insert", "normal", "visual", "visual-line"] as const) {
79
- const raw = source[mode]
80
- if (!raw || typeof raw !== "object") continue
81
- keymaps[mode] = {}
82
- for (const [key, action] of Object.entries(raw as Record<string, unknown>)) {
83
- if (isMappedAction(action)) keymaps[mode][key] = action
84
- }
85
- }
86
-
87
- return keymaps
88
- }
89
-
90
- function readCursorStyles(input: unknown): VimOptions["cursorStyles"] {
91
- if (!input || typeof input !== "object") return undefined
92
- const source = input as Record<string, unknown>
93
- return {
94
- insert: readCursorStyle(source.insert),
95
- normal: readCursorStyle(source.normal),
96
- visual: readCursorStyle(source.visual),
97
- "visual-line": readCursorStyle(source["visual-line"]),
98
- }
99
- }
100
-
101
- function readCursorStyle(input: unknown): VimCursorStyle | undefined {
102
- if (!input || typeof input !== "object") return undefined
103
- const source = input as Record<string, unknown>
104
- if (!isCursorStyle(source.style)) return undefined
105
- return {
106
- style: source.style,
107
- blinking: typeof source.blinking === "boolean" ? source.blinking : undefined,
108
- }
109
- }
110
-
111
- function isCursorStyle(value: unknown): value is VimCursorStyle["style"] {
112
- return value === "block" || value === "line" || value === "underline" || value === "default"
113
- }
114
-
115
- function isMode(value: unknown): value is VimMode {
116
- return value === "insert" || value === "normal" || value === "visual" || value === "visual-line"
117
- }
118
-
119
- function isMappedAction(value: unknown): value is VimMappedAction {
120
- return typeof value === "string" && value.length > 0
121
- }
122
-
123
- function readNumber(value: unknown) {
124
- return typeof value === "number" ? value : undefined
125
- }
@@ -1,51 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import { editInput } from "./edit"
3
-
4
- describe("editInput", () => {
5
- test("inserts without replacing unchanged placeholders", () => {
6
- const fixture = createFixture("[Image 1] after")
7
-
8
- editInput(fixture.input, "[Image 1] kafter")
9
-
10
- expect(fixture.calls).toEqual([["cursor", 10], ["insert", "k"]])
11
- })
12
-
13
- test("uses display offsets for a minimal deletion", () => {
14
- const fixture = createFixture("中 [Image 1] abc")
15
-
16
- editInput(fixture.input, "中 [Image 1] ac")
17
-
18
- expect(fixture.calls).toEqual([["selection", 14, 15], ["insert", ""]])
19
- })
20
-
21
- test("does not split graphemes", () => {
22
- const fixture = createFixture("a👩‍💻b")
23
-
24
- editInput(fixture.input, "axb")
25
-
26
- expect(fixture.calls).toEqual([["selection", 1, 3], ["insert", "x"]])
27
- })
28
- })
29
-
30
- function createFixture(plainText: string) {
31
- const calls: Array<["cursor", number] | ["selection", number, number] | ["insert", string]> = []
32
- let cursorOffset = 0
33
- const input = {
34
- plainText,
35
- clearSelection() {},
36
- get cursorOffset() {
37
- return cursorOffset
38
- },
39
- set cursorOffset(value: number) {
40
- cursorOffset = value
41
- calls.push(["cursor", value])
42
- },
43
- setSelection(start: number, end: number) {
44
- calls.push(["selection", start, end])
45
- },
46
- insertText(text: string) {
47
- calls.push(["insert", text])
48
- },
49
- }
50
- return { input, calls }
51
- }
@@ -1,33 +0,0 @@
1
- import { charToDisplay } from "./map"
2
-
3
- const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
4
-
5
- type Input = {
6
- plainText: string
7
- cursorOffset: number
8
- setSelection: (start: number, end: number) => void
9
- insertText: (text: string) => void
10
- clearSelection: () => unknown
11
- }
12
-
13
- export function editInput(input: Input, value: string) {
14
- if (input.plainText === value) return
15
- input.clearSelection()
16
-
17
- const before = [...graphemes.segment(input.plainText)]
18
- const after = [...graphemes.segment(value)]
19
- let prefix = 0
20
- while (prefix < before.length && prefix < after.length && before[prefix].segment === after[prefix].segment) prefix++
21
-
22
- let suffix = 0
23
- while (suffix + prefix < before.length && suffix + prefix < after.length && before[before.length - suffix - 1].segment === after[after.length - suffix - 1].segment) suffix++
24
-
25
- const start = before[prefix]?.index ?? input.plainText.length
26
- const end = suffix ? before[before.length - suffix].index : input.plainText.length
27
- const valueEnd = suffix ? after[after.length - suffix].index : value.length
28
- const startOffset = charToDisplay(input.plainText, start)
29
-
30
- if (start === end) input.cursorOffset = startOffset
31
- else input.setSelection(startOffset, charToDisplay(input.plainText, end))
32
- input.insertText(value.slice(start, valueEnd))
33
- }
@@ -1,38 +0,0 @@
1
- const segments = new Intl.Segmenter(undefined, { granularity: "grapheme" })
2
-
3
- // Vimee indexes JavaScript strings by UTF-16 code unit. Give each multi-unit
4
- // grapheme one private-use character so motions, operators and registers cannot
5
- // split it. The dictionary lives with the adapter, including its undo history.
6
- export function createGraphemeCodec() {
7
- const encoded = new Map<string, string>()
8
- const decoded = new Map<string, string>()
9
- let next = 0xe000
10
- return {
11
- encode(text: string) {
12
- let result = ""
13
- for (const { segment } of segments.segment(text)) {
14
- const code = segment.charCodeAt(0)
15
- if (segment.length === 1 && (code < 0xe000 || code > 0xf8ff)) {
16
- result += segment
17
- continue
18
- }
19
- let token = encoded.get(segment)
20
- if (!token) {
21
- if (next > 0xf8ff) throw new Error("Vim grapheme dictionary is full")
22
- token = String.fromCharCode(next++)
23
- encoded.set(segment, token)
24
- decoded.set(token, segment)
25
- }
26
- result += token
27
- }
28
- return result
29
- },
30
- decode(text: string) {
31
- let result = ""
32
- for (const token of text) result += decoded.get(token) ?? token
33
- return result
34
- },
35
- }
36
- }
37
-
38
- export type GraphemeCodec = ReturnType<typeof createGraphemeCodec>
@@ -1,200 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import type { KeyEvent, ParsedKey } from "@opentui/core"
3
- import { onCleanup } from "solid-js"
4
- import type { Accessor } from "solid-js"
5
- import type { PromptContext, PromptModule } from "../../prompt/types"
6
- import type { SnippetController } from "../snippets/types"
7
- import { applyVimCursorStyle, focusedInput } from "./actions"
8
- import type { VimConfig } from "./config"
9
- import { createVimConfig } from "./config"
10
- import { displayToChar } from "./map"
11
- import { keyNotation } from "./keys"
12
- import { createVimLog } from "./log"
13
- import type { VimLog } from "./log"
14
- import { createVimState } from "./state"
15
- import { createVimeeAdapter } from "./vimee"
16
- import { VimStatus } from "./view"
17
-
18
- export function createVimModule(options?: unknown, enabled: Accessor<boolean> = () => true, snippets?: SnippetController): PromptModule {
19
- const config = createVimConfig(options)
20
- const log = createVimLog(config)
21
- const state = createVimState(config.defaultMode, log)
22
- log("module.create", { defaultMode: config.defaultMode, keymapTimeout: config.keymapTimeout })
23
-
24
- return {
25
- id: "vim",
26
- order: 0,
27
- setup(ctx) {
28
- log("module.setup", { kind: ctx.kind, sessionID: ctx.sessionID, workspaceID: ctx.workspaceID })
29
- },
30
- renderAbove(ctx) {
31
- return <VimKeyboard ctx={ctx} config={config} state={state} snippets={snippets} enabled={enabled} log={log} />
32
- },
33
- renderRight(ctx) {
34
- 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
- },
36
- }
37
- }
38
-
39
- function VimKeyboard(props: { ctx: PromptContext; config: VimConfig; state: ReturnType<typeof createVimState>; snippets?: SnippetController; enabled: Accessor<boolean>; log: VimLog }) {
40
- let cursorStyleMode = ""
41
- const vimee = createVimeeAdapter(props.state, props.config, props.log)
42
- props.log("keyboard.mount", { kind: props.ctx.kind })
43
-
44
- const cursorStyleTimer = setInterval(syncCursorStyle, 50)
45
- const offKeyboard = props.ctx.api.keymap.intercept("key", ({ event }) => {
46
- props.log("keyboard.event", {
47
- name: event.name,
48
- ctrl: event.ctrl,
49
- meta: event.meta,
50
- shift: event.shift,
51
- defaultPrevented: event.defaultPrevented,
52
- propagationStopped: event.propagationStopped,
53
- mode: props.state.mode(),
54
- pending: props.state.pending(),
55
- })
56
-
57
- if (!canHandleKeys(props)) {
58
- props.log("keyboard.skip", { enabled: props.enabled(), disabled: props.ctx.disabled, visible: props.ctx.visible, dialogOpen: props.ctx.api.ui.dialog.open })
59
- return
60
- }
61
-
62
- const key = keyNotation(event)
63
- if (!key) {
64
- props.log("keyboard.no_key")
65
- return
66
- }
67
-
68
- if (passThroughKey(event, key, props.state.mode())) return
69
-
70
- if (key === "<Esc>" && props.state.mode() === "normal" && !props.state.pending()) {
71
- props.log("keyboard.fallthrough", { key, mode: props.state.mode() })
72
- return
73
- }
74
-
75
- if (sendNavigationKey(event, props.ctx, key, props.state.mode(), props.snippets)) {
76
- syncCursorStyle(true)
77
- props.ctx.requestRender()
78
- return
79
- }
80
-
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
- }
91
- if (consumed) {
92
- syncCursorStyle(true)
93
- props.ctx.requestRender()
94
- }
95
- })
96
-
97
- onCleanup(() => {
98
- props.log("keyboard.cleanup", { kind: props.ctx.kind })
99
- offKeyboard()
100
- vimee.cleanup()
101
- clearInterval(cursorStyleTimer)
102
- })
103
-
104
- return <box height={0} />
105
-
106
- function syncCursorStyle(force = false) {
107
- const mode = props.state.mode()
108
- if (!force && cursorStyleMode === mode) return
109
- if (applyVimCursorStyle(props.ctx, props.config.cursorStyles[mode])) {
110
- cursorStyleMode = mode
111
- props.log("cursor.style", { mode, style: props.config.cursorStyles[mode].style, blinking: props.config.cursorStyles[mode].blinking })
112
- }
113
- }
114
- }
115
-
116
- function canHandleKeys(props: { ctx: PromptContext; enabled: Accessor<boolean> }) {
117
- return props.enabled() && !props.ctx.disabled && props.ctx.visible !== false && !props.ctx.api.ui.dialog.open
118
- }
119
-
120
- function readablePending(sequence: string) {
121
- if (!sequence) return undefined
122
- return sequence.replaceAll("><", " ")
123
- }
124
-
125
- function passThroughKey(event: KeyEvent, key: string, mode: string) {
126
- if (mode !== "normal") return false
127
- return event.super === true || isArrowKey(key) || key === "<C-c>"
128
- }
129
-
130
- function sendNavigationKey(event: KeyEvent, ctx: PromptContext, key: string, mode: string, snippets?: SnippetController) {
131
- if (mode !== "normal") return false
132
- const delta = snippetNavigationDelta(key)
133
- if (delta !== 0 && snippets?.navigate?.(delta)) {
134
- event.preventDefault()
135
- event.stopPropagation()
136
- return true
137
- }
138
-
139
- const forwarded = commandNavigationKey(ctx, key)
140
- if (!forwarded) return false
141
-
142
- event.preventDefault()
143
- event.stopPropagation()
144
- ctx.api.renderer.keyInput.processParsedKey(forwarded)
145
- return true
146
- }
147
-
148
- function snippetNavigationDelta(key: string) {
149
- if (key === "j") return 1
150
- if (key === "k") return -1
151
- return 0
152
- }
153
-
154
- function commandNavigationKey(ctx: PromptContext, key: string): ParsedKey | undefined {
155
- if (!isPromptFocused(ctx)) return navigationKey(key)
156
- if (!isNativeCompletionToken(ctx)) return undefined
157
- if (key === "j") return arrowKey("down", "\u001B[B")
158
- if (key === "k") return arrowKey("up", "\u001B[A")
159
- return undefined
160
- }
161
-
162
- function isPromptFocused(ctx: PromptContext) {
163
- return ctx.prompt()?.focused === true && !!focusedInput(ctx)
164
- }
165
-
166
- function isNativeCompletionToken(ctx: PromptContext) {
167
- const text = ctx.prompt()?.current.input ?? focusedInput(ctx)?.plainText ?? ""
168
- const input = focusedInput(ctx)
169
- const charIdx = displayToChar(text, Math.max(0, input?.cursorOffset ?? 0))
170
- const beforeCursor = text.slice(0, Math.min(charIdx + 1, text.length))
171
- return /^\/\S*$/.test(beforeCursor) || /(?:^|\s)@\S*$/.test(beforeCursor)
172
- }
173
-
174
- function navigationKey(key: string): ParsedKey | undefined {
175
- if (key === "h") return arrowKey("left", "\u001B[D")
176
- if (key === "j") return arrowKey("down", "\u001B[B")
177
- if (key === "k") return arrowKey("up", "\u001B[A")
178
- if (key === "l") return arrowKey("right", "\u001B[C")
179
- return undefined
180
- }
181
-
182
- function isArrowKey(key: string) {
183
- return key === "<Left>" || key === "<Down>" || key === "<Up>" || key === "<Right>"
184
- }
185
-
186
- function arrowKey(name: string, sequence: string): ParsedKey {
187
- return {
188
- name,
189
- ctrl: false,
190
- meta: false,
191
- shift: false,
192
- option: false,
193
- sequence,
194
- number: false,
195
- raw: sequence,
196
- eventType: "press",
197
- source: "raw",
198
- super: false,
199
- }
200
- }
@@ -1,38 +0,0 @@
1
- import type { KeyEvent } from "@opentui/core"
2
-
3
- const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
4
-
5
- const NAMED_KEYS: Record<string, string> = {
6
- escape: "<Esc>",
7
- esc: "<Esc>",
8
- return: "<CR>",
9
- enter: "<CR>",
10
- tab: "<Tab>",
11
- backspace: "<BS>",
12
- delete: "<Del>",
13
- space: "<Space>",
14
- up: "<Up>",
15
- down: "<Down>",
16
- left: "<Left>",
17
- right: "<Right>",
18
- home: "<Home>",
19
- end: "<End>",
20
- pageup: "<PageUp>",
21
- pagedown: "<PageDown>",
22
- }
23
-
24
- export function keyNotation(event: KeyEvent) {
25
- const name = event.name?.toLowerCase()
26
- if (!name) return undefined
27
-
28
- if (event.ctrl) return `<C-${ctrlKey(name)}>`
29
- if (event.meta) return `<M-${name}>`
30
- if ([...graphemes.segment(name)].length === 1) return event.shift ? name.toUpperCase() : name
31
- return NAMED_KEYS[name] ?? `<${name}>`
32
- }
33
-
34
- function ctrlKey(name: string) {
35
- if (name === "escape" || name === "esc") return "["
36
- if (name.length === 1) return name
37
- return name
38
- }
@@ -1,37 +0,0 @@
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
- }
@@ -1,45 +0,0 @@
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
- })