opencode-vim 0.0.23 → 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 +11 -0
  2. package/dist/tui.js +3326 -0
  3. package/package.json +6 -6
  4. package/src/clipboard.tsx +0 -50
  5. package/src/modules/snippets/index.tsx +0 -17
  6. package/src/modules/snippets/loader.ts +0 -147
  7. package/src/modules/snippets/search.ts +0 -118
  8. package/src/modules/snippets/skill-loader.ts +0 -81
  9. package/src/modules/snippets/state.ts +0 -40
  10. package/src/modules/snippets/trigger.ts +0 -68
  11. package/src/modules/snippets/types.ts +0 -50
  12. package/src/modules/snippets/view.tsx +0 -674
  13. package/src/modules/vim/actions.ts +0 -82
  14. package/src/modules/vim/config.ts +0 -137
  15. package/src/modules/vim/edit.test.ts +0 -51
  16. package/src/modules/vim/edit.ts +0 -33
  17. package/src/modules/vim/graphemes.ts +0 -38
  18. package/src/modules/vim/index.tsx +0 -200
  19. package/src/modules/vim/keys.ts +0 -38
  20. package/src/modules/vim/log.ts +0 -37
  21. package/src/modules/vim/map.test.ts +0 -45
  22. package/src/modules/vim/map.ts +0 -90
  23. package/src/modules/vim/state.test.ts +0 -18
  24. package/src/modules/vim/state.tsx +0 -46
  25. package/src/modules/vim/view.tsx +0 -130
  26. package/src/modules/vim/vimee.test.ts +0 -211
  27. package/src/modules/vim/vimee.ts +0 -1211
  28. package/src/plugin.tsx +0 -116
  29. package/src/prompt/host.tsx +0 -38
  30. package/src/prompt/modules.ts +0 -16
  31. package/src/prompt/root.tsx +0 -69
  32. package/src/prompt/types.ts +0 -42
  33. package/src/session.tsx +0 -291
  34. package/src/transcript-items.tsx +0 -168
  35. package/src/transcript.tsx +0 -179
  36. package/src/update.ts +0 -120
  37. package/tui.tsx +0 -340
  38. package/view.tsx +0 -29
@@ -1,82 +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
- cursorOffset?: number
30
- plainText?: string
31
- visualCursor?: VisualCursorLike
32
- editorView?: { getLogicalLineInfo?: () => LineInfo; setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void; resetSelection?: () => void }
33
- cursorStyle?: CursorStyleOptions
34
- selectionBg?: RGBA
35
- selectionFg?: RGBA
36
- moveCursorLeft?: () => boolean
37
- moveCursorRight?: () => boolean
38
- moveCursorUp?: () => boolean
39
- moveCursorDown?: () => boolean
40
- setSelection?: (start: number, end: number) => void
41
- insertText?: (text: string) => void
42
- setSelectionInclusive?: (start: number, end: number) => void
43
- clearSelection?: () => void
44
- gotoLineEnd?: () => void
45
- }
46
-
47
- export type VisualCursorLike = {
48
- visualRow?: number
49
- visualCol?: number
50
- logicalRow?: number
51
- logicalCol?: number
52
- offset?: number
53
- }
54
-
55
- export function applyVimCursorStyle(ctx: PromptContext, style: VimCursorStyle) {
56
- const input = focusedInput(ctx)
57
- if (!input) return false
58
- input.cursorStyle = style
59
- return true
60
- }
61
-
62
- export function focusedInput(ctx: PromptContext): EditBufferLike | undefined {
63
- const focused = ctx.api.renderer.currentFocusedRenderable as EditBufferLike | null | undefined
64
- if (!focused || !hasEditBufferMethods(focused)) return undefined
65
- return focused
66
- }
67
-
68
- export function setInput(ref: PromptRef, input: string) {
69
- ref.set(toPromptInfo(ref, input))
70
- }
71
-
72
- function hasEditBufferMethods(input: EditBufferLike) {
73
- return typeof input.moveCursorLeft === "function" || typeof input.moveCursorRight === "function" || typeof input.moveCursorUp === "function" || typeof input.moveCursorDown === "function" || typeof input.gotoLineEnd === "function"
74
- }
75
-
76
- function toPromptInfo(ref: PromptRef, input: string): PromptInfo {
77
- return {
78
- input,
79
- mode: ref.current.mode,
80
- parts: [...ref.current.parts],
81
- }
82
- }
@@ -1,137 +0,0 @@
1
- import { parseKeySequence } from "@vimee/core"
2
- import type { VimMode } from "./state"
3
-
4
- export type VimCursorStyle = {
5
- style: "block" | "line" | "underline" | "default"
6
- blinking?: boolean
7
- }
8
-
9
- export type VimConfig = {
10
- defaultMode: VimMode
11
- sessionKey: string
12
- keymapTimeout: number
13
- pendingDisplayDelay: number
14
- cursorStyles: Record<VimMode, VimCursorStyle>
15
- debug: boolean
16
- debugPath?: string
17
- keymaps: VimKeymaps
18
- }
19
-
20
- export type VimOptions = {
21
- defaultMode?: VimMode
22
- sessionKey?: string
23
- keymapTimeout?: number
24
- pendingDisplayDelay?: number
25
- cursorStyles?: Partial<Record<VimMode, VimCursorStyle>>
26
- debug?: boolean
27
- debugPath?: string
28
- keymaps?: VimKeymaps
29
- }
30
-
31
- export type VimKeymaps = Partial<Record<VimMode, Record<string, VimMappedAction>>>
32
- export type VimMappedAction = string
33
-
34
- const DEFAULT_CURSOR_STYLES: Record<VimMode, VimCursorStyle> = {
35
- insert: { style: "line", blinking: true },
36
- normal: { style: "block", blinking: true },
37
- visual: { style: "block", blinking: true },
38
- "visual-line": { style: "block", blinking: true },
39
- }
40
-
41
- export function createVimConfig(options: unknown): VimConfig {
42
- const input = readOptions(options)
43
- return {
44
- defaultMode: input.defaultMode ?? "insert",
45
- sessionKey: input.sessionKey ?? "s",
46
- keymapTimeout: Math.max(0, input.keymapTimeout ?? 500),
47
- pendingDisplayDelay: Math.max(0, input.pendingDisplayDelay ?? 120),
48
- cursorStyles: {
49
- insert: { ...DEFAULT_CURSOR_STYLES.insert, ...input.cursorStyles?.insert },
50
- normal: { ...DEFAULT_CURSOR_STYLES.normal, ...input.cursorStyles?.normal },
51
- visual: { ...DEFAULT_CURSOR_STYLES.visual, ...input.cursorStyles?.visual },
52
- "visual-line": { ...DEFAULT_CURSOR_STYLES["visual-line"], ...input.cursorStyles?.["visual-line"] },
53
- },
54
- debug: input.debug ?? process.env.VIM_PROMPT_DEBUG === "1",
55
- debugPath: input.debugPath,
56
- keymaps: input.keymaps ?? {},
57
- }
58
- }
59
-
60
- function readOptions(options: unknown): VimOptions {
61
- if (!options || typeof options !== "object") return {}
62
- const raw = "vim" in options ? (options as { vim?: unknown }).vim : options
63
- if (!raw || typeof raw !== "object") return {}
64
-
65
- const source = raw as Record<string, unknown>
66
- return {
67
- defaultMode: isMode(source.defaultMode) ? source.defaultMode : undefined,
68
- sessionKey: readSessionKey(source.sessionKey),
69
- keymapTimeout: readNumber(source.keymapTimeout),
70
- pendingDisplayDelay: typeof source.pendingDisplayDelay === "number" ? source.pendingDisplayDelay : undefined,
71
- cursorStyles: readCursorStyles(source.cursorStyles),
72
- debug: typeof source.debug === "boolean" ? source.debug : undefined,
73
- debugPath: typeof source.debugPath === "string" ? source.debugPath : undefined,
74
- keymaps: readKeymaps(source.keymaps),
75
- }
76
- }
77
-
78
- function readSessionKey(input: unknown): string | undefined {
79
- if (typeof input !== "string") return
80
- try {
81
- if (parseKeySequence(input).length === 1) return input
82
- } catch {}
83
- }
84
-
85
- function readKeymaps(input: unknown): VimKeymaps | undefined {
86
- if (!input || typeof input !== "object") return undefined
87
- const source = input as Record<string, unknown>
88
- const keymaps: VimKeymaps = {}
89
-
90
- for (const mode of ["insert", "normal", "visual", "visual-line"] as const) {
91
- const raw = source[mode]
92
- if (!raw || typeof raw !== "object") continue
93
- keymaps[mode] = {}
94
- for (const [key, action] of Object.entries(raw as Record<string, unknown>)) {
95
- if (isMappedAction(action)) keymaps[mode][key] = action
96
- }
97
- }
98
-
99
- return keymaps
100
- }
101
-
102
- function readCursorStyles(input: unknown): VimOptions["cursorStyles"] {
103
- if (!input || typeof input !== "object") return undefined
104
- const source = input as Record<string, unknown>
105
- return {
106
- insert: readCursorStyle(source.insert),
107
- normal: readCursorStyle(source.normal),
108
- visual: readCursorStyle(source.visual),
109
- "visual-line": readCursorStyle(source["visual-line"]),
110
- }
111
- }
112
-
113
- function readCursorStyle(input: unknown): VimCursorStyle | undefined {
114
- if (!input || typeof input !== "object") return undefined
115
- const source = input as Record<string, unknown>
116
- if (!isCursorStyle(source.style)) return undefined
117
- return {
118
- style: source.style,
119
- blinking: typeof source.blinking === "boolean" ? source.blinking : undefined,
120
- }
121
- }
122
-
123
- function isCursorStyle(value: unknown): value is VimCursorStyle["style"] {
124
- return value === "block" || value === "line" || value === "underline" || value === "default"
125
- }
126
-
127
- function isMode(value: unknown): value is VimMode {
128
- return value === "insert" || value === "normal" || value === "visual" || value === "visual-line"
129
- }
130
-
131
- function isMappedAction(value: unknown): value is VimMappedAction {
132
- return typeof value === "string" && value.length > 0
133
- }
134
-
135
- function readNumber(value: unknown) {
136
- return typeof value === "number" ? value : undefined
137
- }
@@ -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
- })