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
|
@@ -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
|
-
}
|
package/src/modules/vim/edit.ts
DELETED
|
@@ -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
|
-
}
|
package/src/modules/vim/keys.ts
DELETED
|
@@ -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
|
-
}
|
package/src/modules/vim/log.ts
DELETED
|
@@ -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
|
-
})
|