opencode-vim 0.0.10 → 0.0.11
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 +14 -1
- package/assets/demo.gif +0 -0
- package/assets/demo2.gif +0 -0
- package/package.json +1 -1
- package/src/modules/vim/vimee.ts +246 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Adds Vim-style insert and normal mode editing to the OpenCode prompt.
|
|
4
4
|
|
|
5
|
-

|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -12,4 +12,17 @@ Install from the CLI:
|
|
|
12
12
|
opencode plugin opencode-vim@latest --global
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
## Supported Keys
|
|
16
|
+
|
|
17
|
+
| Key | Behavior |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `<Esc>`, `<C-[>` | Enter normal mode |
|
|
20
|
+
| `i`, `a`, `A`, `o`, `O` | Return to insert mode |
|
|
21
|
+
| `h`, `j`, `k`, `l`, `w`, `b`, `e`, `$`, `0` | Move through the prompt |
|
|
22
|
+
| `x`, `d`, `c`, `y`, `p`, `u`, `<C-r>` | Edit, yank, paste, undo, redo |
|
|
23
|
+
| `v`, `V` | Visual and visual-line selection |
|
|
24
|
+
| `3w`, `diw`, `ci"`, `yiq`, `dip`, `yib` | Counts and text objects |
|
|
25
|
+
| `<CR>` in normal mode | Submit the prompt |
|
|
26
|
+
| `/vim` | Toggle Vim mode on or off |
|
|
27
|
+
|
|
15
28
|
See [docs/configuration.md](./docs/configuration.md) for configuration options and keymap examples.
|
package/assets/demo.gif
CHANGED
|
Binary file
|
package/assets/demo2.gif
ADDED
|
Binary file
|
package/package.json
CHANGED
package/src/modules/vim/vimee.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { KeyEvent } from "@opentui/core"
|
|
2
|
-
import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke } from "@vimee/core"
|
|
3
|
-
import type { CursorPosition, KeybindDefinition, KeybindMap, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
|
|
2
|
+
import { TextBuffer, createInitialContext, createKeybindMap, parseKeySequence, processKeystroke, resetContext } from "@vimee/core"
|
|
3
|
+
import type { CursorPosition, KeybindDefinition, KeybindMap, MotionRange, Operator, ValidKeySequence, VimAction as VimeeAction, VimContext, VimMode as VimeeMode } from "@vimee/core"
|
|
4
4
|
import type { PromptContext } from "../../prompt/types"
|
|
5
5
|
import { focusedInput, setInput, type EditBufferLike } from "./actions"
|
|
6
6
|
import type { VimConfig } from "./config"
|
|
@@ -38,7 +38,7 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
38
38
|
const offset = clamp(input?.cursorOffset ?? text.length, 0, text.length)
|
|
39
39
|
const map = mapForHostText(text, input)
|
|
40
40
|
const cursor = hostPosition(map, offset)
|
|
41
|
-
|
|
41
|
+
let vimeeKey = keyForVimee(event, key)
|
|
42
42
|
if (!vimeeKey) return false
|
|
43
43
|
|
|
44
44
|
if (state.mode() === "normal" && key === "<CR>") {
|
|
@@ -70,6 +70,9 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
70
70
|
const hostEnd = vimeeKey === "e" ? endMotionOffset(map.hostText, offset, vim.count || 1) : undefined
|
|
71
71
|
const shouldFlashYank = shouldFlashYankFor(vimeeKey)
|
|
72
72
|
const visualYankRange = visualYankRangeFor(map)
|
|
73
|
+
vimeeKey = textObjectAlias(vimeeKey, vim) ?? vimeeKey
|
|
74
|
+
const textObjectHandled = handleTextObject(vimeeKey, ctx, map)
|
|
75
|
+
if (textObjectHandled !== undefined) return textObjectHandled
|
|
73
76
|
const result = processKeystroke(vimeeKey, vim, buffer, event.ctrl, false, keybinds)
|
|
74
77
|
vim = result.newCtx
|
|
75
78
|
if (hostEnd !== undefined && result.actions.every((action) => action.type === "cursor-move") && hostEnd > hostOffset(map, vim.cursor, "previous")) vim = { ...vim, cursor: hostPosition(map, hostEnd) }
|
|
@@ -230,6 +233,39 @@ export function createVimeeAdapter(state: VimState, config: VimConfig, log: VimL
|
|
|
230
233
|
return actions
|
|
231
234
|
}
|
|
232
235
|
|
|
236
|
+
function handleTextObject(key: string, ctx: PromptContext, map: PromptMap) {
|
|
237
|
+
if (vim.phase !== "text-object-pending" || !vim.textObjectModifier) return undefined
|
|
238
|
+
const range = resolvePromptTextObject(vim.textObjectModifier, key, vim.cursor, buffer)
|
|
239
|
+
if (!range) return undefined
|
|
240
|
+
|
|
241
|
+
const flashRange = vim.operator === "y" ? motionHostRange(map, range) : undefined
|
|
242
|
+
|
|
243
|
+
if (!vim.operator) {
|
|
244
|
+
vim = { ...resetContext(vim), visualAnchor: range.start, cursor: range.end }
|
|
245
|
+
applyActions([{ type: "cursor-move", position: range.end }], ctx, map)
|
|
246
|
+
state.setPending("")
|
|
247
|
+
updateTimeout(ctx)
|
|
248
|
+
log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: ["cursor-move"] })
|
|
249
|
+
return true
|
|
250
|
+
}
|
|
251
|
+
if (!textObjectOperator(vim.operator)) return undefined
|
|
252
|
+
|
|
253
|
+
const originalCursor = vim.cursor
|
|
254
|
+
const result = executeTextObject(vim.operator, range, buffer, vim)
|
|
255
|
+
if (vim.operator === "y") {
|
|
256
|
+
result.context = { ...result.context, cursor: originalCursor }
|
|
257
|
+
result.actions = result.actions.map((action) => (action.type === "cursor-move" ? { ...action, position: originalCursor } : action))
|
|
258
|
+
}
|
|
259
|
+
vim = result.context
|
|
260
|
+
applyActions(result.actions, ctx, map)
|
|
261
|
+
if (flashRange) flashYank(ctx, activeMap, { type: "yank", text: result.yankedText }, flashRange)
|
|
262
|
+
syncMode(state, vim.mode)
|
|
263
|
+
state.setPending("")
|
|
264
|
+
updateTimeout(ctx)
|
|
265
|
+
log("vimee.textobject", { key, mode: vim.mode, phase: vim.phase, cursor: vim.cursor, actions: result.actions.map((action) => action.type) })
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
|
|
233
269
|
function updateTimeout(ctx: PromptContext) {
|
|
234
270
|
if (timer) clearTimeout(timer)
|
|
235
271
|
timer = undefined
|
|
@@ -370,6 +406,13 @@ function yankedTextRange(map: PromptMap, cursor: CursorPosition, text: string):
|
|
|
370
406
|
return vimOffsetRange(map, start, start + text.length - 1)
|
|
371
407
|
}
|
|
372
408
|
|
|
409
|
+
function motionHostRange(map: PromptMap, range: MotionRange): HostRange | undefined {
|
|
410
|
+
if (range.linewise) return visualLineRange(map, range.start, range.end)
|
|
411
|
+
const start = vimOffsetFromPosition(map.vimText, range.start)
|
|
412
|
+
const end = vimOffsetFromPosition(map.vimText, range.end)
|
|
413
|
+
return vimOffsetRange(map, start, end)
|
|
414
|
+
}
|
|
415
|
+
|
|
373
416
|
function vimOffsetRange(map: PromptMap, left: number, right: number): HostRange | undefined {
|
|
374
417
|
const start = hostFromVimOffset(map, Math.min(left, right), "next")
|
|
375
418
|
const end = hostFromVimOffset(map, Math.max(left, right), "previous")
|
|
@@ -572,6 +615,206 @@ function consumesKey(key: string, actions: VimeeAction[], ctx: VimContext, keybi
|
|
|
572
615
|
return ctx.mode !== "insert" || key === "Escape"
|
|
573
616
|
}
|
|
574
617
|
|
|
618
|
+
function textObjectAlias(key: string, ctx: VimContext) {
|
|
619
|
+
if (ctx.phase !== "text-object-pending") return undefined
|
|
620
|
+
if (key === "b") return "("
|
|
621
|
+
if (key === "B") return "{"
|
|
622
|
+
return undefined
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function textObjectOperator(operator: Operator): operator is "y" | "d" | "c" {
|
|
626
|
+
return operator === "y" || operator === "d" || operator === "c"
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function resolvePromptTextObject(modifier: "i" | "a", key: string, cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
|
|
630
|
+
if (key === "q") return quoteRange(modifier, cursor, buffer)
|
|
631
|
+
if (key !== "p") return null
|
|
632
|
+
return paragraphRange(modifier, cursor, buffer)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function quoteRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
|
|
636
|
+
const pair = quoteObjectPair(cursor, buffer)
|
|
637
|
+
if (!pair) return null
|
|
638
|
+
const start = modifier === "i" ? pair.open + 1 : pair.open
|
|
639
|
+
const end = modifier === "i" ? pair.close - 1 : pair.close
|
|
640
|
+
return {
|
|
641
|
+
start: { line: cursor.line, col: start },
|
|
642
|
+
end: { line: cursor.line, col: Math.max(start, end) },
|
|
643
|
+
linewise: false,
|
|
644
|
+
inclusive: true,
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function paragraphRange(modifier: "i" | "a", cursor: CursorPosition, buffer: TextBuffer): MotionRange | null {
|
|
649
|
+
const count = buffer.getLineCount()
|
|
650
|
+
if (count === 0) return null
|
|
651
|
+
|
|
652
|
+
let start = clamp(cursor.line, 0, count - 1)
|
|
653
|
+
while (start < count && blankLine(buffer.getLine(start))) start++
|
|
654
|
+
if (start >= count) {
|
|
655
|
+
start = clamp(cursor.line, 0, count - 1)
|
|
656
|
+
while (start >= 0 && blankLine(buffer.getLine(start))) start--
|
|
657
|
+
}
|
|
658
|
+
if (start < 0 || start >= count) return null
|
|
659
|
+
|
|
660
|
+
let end = start
|
|
661
|
+
while (start > 0 && !blankLine(buffer.getLine(start - 1))) start--
|
|
662
|
+
while (end < count - 1 && !blankLine(buffer.getLine(end + 1))) end++
|
|
663
|
+
|
|
664
|
+
if (modifier === "a") {
|
|
665
|
+
if (end < count - 1 && blankLine(buffer.getLine(end + 1))) {
|
|
666
|
+
end++
|
|
667
|
+
while (end < count - 1 && blankLine(buffer.getLine(end + 1))) end++
|
|
668
|
+
} else {
|
|
669
|
+
while (start > 0 && blankLine(buffer.getLine(start - 1))) start--
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
start: { line: start, col: 0 },
|
|
675
|
+
end: { line: end, col: Math.max(0, buffer.getLineLength(end) - 1) },
|
|
676
|
+
linewise: true,
|
|
677
|
+
inclusive: true,
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function quoteObjectPair(cursor: CursorPosition, buffer: TextBuffer) {
|
|
682
|
+
let best: { open: number; close: number; distance: number } | undefined
|
|
683
|
+
for (const quote of ['"', "'", "`"] as const) {
|
|
684
|
+
const pair = quotePair(cursor, buffer, quote)
|
|
685
|
+
if (!pair) continue
|
|
686
|
+
if (!best || pair.distance < best.distance) best = pair
|
|
687
|
+
}
|
|
688
|
+
return best
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function quotePair(cursor: CursorPosition, buffer: TextBuffer, quote: string) {
|
|
692
|
+
const line = buffer.getLine(cursor.line)
|
|
693
|
+
let open = -1
|
|
694
|
+
let close = -1
|
|
695
|
+
let inQuote = false
|
|
696
|
+
let quoteStart = -1
|
|
697
|
+
|
|
698
|
+
for (let index = 0; index < line.length; index++) {
|
|
699
|
+
if (line[index] !== quote || escaped(line, index)) continue
|
|
700
|
+
if (!inQuote) {
|
|
701
|
+
quoteStart = index
|
|
702
|
+
inQuote = true
|
|
703
|
+
continue
|
|
704
|
+
}
|
|
705
|
+
if (cursor.col >= quoteStart && cursor.col <= index) {
|
|
706
|
+
return { open: quoteStart, close: index, distance: 0 }
|
|
707
|
+
}
|
|
708
|
+
inQuote = false
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
for (let index = cursor.col; index < line.length; index++) {
|
|
712
|
+
if (line[index] !== quote || escaped(line, index)) continue
|
|
713
|
+
if (open === -1) open = index
|
|
714
|
+
else {
|
|
715
|
+
close = index
|
|
716
|
+
break
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (open !== -1 && close !== -1) return { open, close, distance: open - cursor.col }
|
|
720
|
+
|
|
721
|
+
close = -1
|
|
722
|
+
open = -1
|
|
723
|
+
for (let index = cursor.col; index >= 0; index--) {
|
|
724
|
+
if (line[index] !== quote || escaped(line, index)) continue
|
|
725
|
+
if (close === -1) close = index
|
|
726
|
+
else {
|
|
727
|
+
open = index
|
|
728
|
+
break
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
if (open !== -1 && close !== -1) return { open, close, distance: cursor.col - close }
|
|
732
|
+
return undefined
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function executeTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer, ctx: VimContext) {
|
|
736
|
+
buffer.saveUndoPoint(ctx.cursor)
|
|
737
|
+
const result = range.linewise ? executeLinewiseTextObject(operator, range, buffer) : executeCharwiseTextObject(operator, range, buffer)
|
|
738
|
+
const registers = ctx.selectedRegister ? { ...ctx.registers, [ctx.selectedRegister]: result.yankedText } : ctx.registers
|
|
739
|
+
const context = {
|
|
740
|
+
...resetContext(ctx),
|
|
741
|
+
mode: result.mode,
|
|
742
|
+
cursor: result.cursor,
|
|
743
|
+
register: result.yankedText,
|
|
744
|
+
registers,
|
|
745
|
+
statusMessage: result.statusMessage,
|
|
746
|
+
}
|
|
747
|
+
const actions: VimeeAction[] = [{ type: "yank", text: result.yankedText }, ...result.actions, { type: "mode-change", mode: result.mode }, { type: "cursor-move", position: result.cursor }]
|
|
748
|
+
return { actions, context, yankedText: result.yankedText }
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function executeLinewiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
|
|
752
|
+
const startLine = Math.min(range.start.line, range.end.line)
|
|
753
|
+
const endLine = Math.max(range.start.line, range.end.line)
|
|
754
|
+
const lineCount = endLine - startLine + 1
|
|
755
|
+
const yankedText = buffer.getLines().slice(startLine, endLine + 1).join("\n") + "\n"
|
|
756
|
+
if (operator === "y") {
|
|
757
|
+
return { actions: [] as VimeeAction[], cursor: { line: startLine, col: 0 }, mode: "normal" as VimeeMode, yankedText, statusMessage: lineCount >= 2 ? `${lineCount} lines yanked` : "" }
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
buffer.deleteLines(startLine, lineCount)
|
|
761
|
+
if (buffer.getLineCount() === 0) buffer.insertLine(0, "")
|
|
762
|
+
const line = Math.min(startLine, buffer.getLineCount() - 1)
|
|
763
|
+
if (operator === "c") buffer.insertLine(line, "")
|
|
764
|
+
return {
|
|
765
|
+
actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
|
|
766
|
+
cursor: { line, col: 0 },
|
|
767
|
+
mode: (operator === "c" ? "insert" : "normal") as VimeeMode,
|
|
768
|
+
yankedText,
|
|
769
|
+
statusMessage: lineCount >= 2 ? `${lineCount} fewer lines` : "",
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function executeCharwiseTextObject(operator: Operator, range: MotionRange, buffer: TextBuffer) {
|
|
774
|
+
const ordered = orderedRange(range)
|
|
775
|
+
const endCol = range.inclusive ? ordered.end.col + 1 : ordered.end.col
|
|
776
|
+
const yankedText = textInRange(buffer, ordered.start, { line: ordered.end.line, col: endCol })
|
|
777
|
+
if (operator === "y") {
|
|
778
|
+
return { actions: [] as VimeeAction[], cursor: ordered.start, mode: "normal" as VimeeMode, yankedText, statusMessage: yankedText.split("\n").length >= 2 ? `${yankedText.split("\n").length} lines yanked` : "" }
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const linesBefore = buffer.getLineCount()
|
|
782
|
+
buffer.deleteRange(ordered.start.line, ordered.start.col, ordered.end.line, endCol)
|
|
783
|
+
const linesRemoved = linesBefore - buffer.getLineCount()
|
|
784
|
+
return {
|
|
785
|
+
actions: [{ type: "content-change", content: buffer.getContent() }] as VimeeAction[],
|
|
786
|
+
cursor: { line: ordered.start.line, col: operator === "c" ? ordered.start.col : Math.min(ordered.start.col, Math.max(0, buffer.getLineLength(ordered.start.line) - 1)) },
|
|
787
|
+
mode: (operator === "c" ? "insert" : "normal") as VimeeMode,
|
|
788
|
+
yankedText,
|
|
789
|
+
statusMessage: linesRemoved >= 2 ? `${linesRemoved} fewer lines` : "",
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function orderedRange(range: MotionRange) {
|
|
794
|
+
if (range.start.line > range.end.line || (range.start.line === range.end.line && range.start.col > range.end.col)) {
|
|
795
|
+
return { start: range.end, end: range.start }
|
|
796
|
+
}
|
|
797
|
+
return { start: range.start, end: range.end }
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function textInRange(buffer: TextBuffer, start: CursorPosition, end: CursorPosition) {
|
|
801
|
+
if (start.line === end.line) return buffer.getLine(start.line).slice(start.col, end.col)
|
|
802
|
+
const lines = [buffer.getLine(start.line).slice(start.col)]
|
|
803
|
+
for (let line = start.line + 1; line < end.line; line++) lines.push(buffer.getLine(line))
|
|
804
|
+
lines.push(buffer.getLine(end.line).slice(0, end.col))
|
|
805
|
+
return lines.join("\n")
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function blankLine(line: string) {
|
|
809
|
+
return line.trim().length === 0
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function escaped(line: string, index: number) {
|
|
813
|
+
let slashCount = 0
|
|
814
|
+
for (let cursor = index - 1; cursor >= 0 && line[cursor] === "\\"; cursor--) slashCount++
|
|
815
|
+
return slashCount % 2 === 1
|
|
816
|
+
}
|
|
817
|
+
|
|
575
818
|
function clamp(value: number, min: number, max: number) {
|
|
576
819
|
return Math.max(min, Math.min(max, value))
|
|
577
820
|
}
|