opencode-vim 0.0.20 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -26
- package/package.json +18 -16
- package/src/modules/vim/actions.ts +32 -7
- package/src/modules/vim/edit.test.ts +51 -0
- package/src/modules/vim/edit.ts +33 -0
- package/src/modules/vim/graphemes.ts +38 -0
- package/src/modules/vim/keys.ts +5 -1
- package/src/modules/vim/map.ts +26 -26
- package/src/modules/vim/vimee.test.ts +1 -1
- package/src/modules/vim/vimee.ts +238 -42
- package/tui.tsx +281 -1
- package/view.tsx +46 -0
- package/tui.json.example +0 -37
package/README.md
CHANGED
|
@@ -1,40 +1,80 @@
|
|
|
1
1
|
# opencode-vim
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Vim-style editing for the OpenCode 2 prompt and dialog search fields.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This branch supports normal, insert, visual, and visual-line editing, custom
|
|
6
|
+
keymaps, prompt history, command dispatch, cursor styles, and `/vim` toggling.
|
|
7
|
+
The mode indicator appears before the working directory in the prompt footer.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Configure the local plugin in OpenCode 2's `cli.json`:
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"plugins": [
|
|
14
|
+
"opencode-vim@file:/home/dan/src/opencode-config/plugin/opencode-vim"
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Options use the V2 object form:
|
|
10
20
|
|
|
11
|
-
```
|
|
12
|
-
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"package": "opencode-vim@file:/home/dan/src/opencode-config/plugin/opencode-vim",
|
|
24
|
+
"options": {
|
|
25
|
+
"vim": {
|
|
26
|
+
"defaultMode": "insert",
|
|
27
|
+
"keymapTimeout": 500,
|
|
28
|
+
"keymaps": {
|
|
29
|
+
"insert": { "kj": "normal" },
|
|
30
|
+
"normal": { "Y": "y$" }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
13
35
|
```
|
|
14
36
|
|
|
15
|
-
|
|
37
|
+
OpenCode 2 does not yet expose its prompt ref or low-level key interceptor to
|
|
38
|
+
plugins. This adapter uses the public renderer in the V2 plugin context to
|
|
39
|
+
bridge those capabilities to the existing Vim engine.
|
|
16
40
|
|
|
17
|
-
|
|
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
|
-
| `k`, `j` on an empty prompt | Browse previous and next prompts |
|
|
26
|
-
| `<CR>` in normal mode | Submit the prompt (configurable) |
|
|
27
|
-
| `/vim` | Toggle Vim mode on or off |
|
|
41
|
+
## Dialogs
|
|
28
42
|
|
|
29
|
-
|
|
43
|
+
Searchable dialogs such as `/models`, the `Ctrl+P` command palette, and Settings
|
|
44
|
+
support Vim editing in their search fields. Each new dialog starts in your
|
|
45
|
+
configured `defaultMode`.
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
- In insert mode, type to filter. `Esc` (or your `kj` mapping) enters normal mode.
|
|
48
|
+
- In normal mode, `j`/`k` move the selected list item. `h`/`l`, `x`, `dw`, `u`,
|
|
49
|
+
and other editing commands act on the search text. `i` returns to insert mode.
|
|
50
|
+
- `Enter` selects the highlighted item; `Esc` in idle normal mode closes or goes
|
|
51
|
+
back. Arrows, Tab, Home/End, and Page Up/Down keep their dialog behavior.
|
|
52
|
+
- Dialog editing has separate mode and undo state from the main prompt.
|
|
32
53
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
Normal-mode custom mappings take precedence over the dialog `j`/`k` defaults.
|
|
55
|
+
|
|
56
|
+
## Development and testing
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npm install
|
|
60
|
+
npm test
|
|
61
|
+
npm run typecheck
|
|
62
|
+
npm run bench
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Tests use Bun and OpenTUI's native headless renderer: real textarea input,
|
|
66
|
+
cursor movement, selection, wrapping, attachment extmarks, and the mounted
|
|
67
|
+
plugin footer. They do not contact an OpenCode server or a model. When `nvim`
|
|
68
|
+
is installed, additional tests compare text and cursor results against headless
|
|
69
|
+
Neovim with user configuration disabled.
|
|
70
|
+
|
|
71
|
+
With an OpenCode source checkout and its dependencies installed, you can also
|
|
72
|
+
test against its real dialog component and keyboard routing:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
OPENCODE_SOURCE=/path/to/opencode npm test -- test/opencode-dialog.test.tsx
|
|
38
76
|
```
|
|
39
77
|
|
|
40
|
-
|
|
78
|
+
The benchmark measures wrap mapping and normal-mode navigation on prompts of
|
|
79
|
+
1,000, 5,000, and 10,000 characters. Wrapping follows the textarea's visual
|
|
80
|
+
lines; those cases are tested separately from Neovim's logical-line behavior.
|
package/package.json
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
2
3
|
"name": "opencode-vim",
|
|
3
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.21",
|
|
5
|
+
"type": "module",
|
|
4
6
|
"repository": {
|
|
5
7
|
"type": "git",
|
|
6
8
|
"url": "https://github.com/Tarquinen/opencode-vim"
|
|
7
9
|
},
|
|
8
10
|
"exports": {
|
|
9
|
-
"./tui":
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
"./tui": "./tui.tsx"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "bun test --conditions=browser --preload @opentui/solid/preload",
|
|
16
|
+
"bench": "bun test/benchmark.ts"
|
|
12
17
|
},
|
|
13
18
|
"dependencies": {
|
|
14
19
|
"@vimee/core": "^0.3.0"
|
|
15
20
|
},
|
|
16
21
|
"peerDependencies": {
|
|
17
|
-
"@opencode
|
|
18
|
-
"@opentui/core": "
|
|
19
|
-
"@opentui/
|
|
20
|
-
"
|
|
21
|
-
"solid-js": "^1.9.12"
|
|
22
|
+
"@opencode/plugin": "2.0.10",
|
|
23
|
+
"@opentui/core": ">=0.5.10",
|
|
24
|
+
"@opentui/solid": ">=0.5.10",
|
|
25
|
+
"solid-js": ">=1.9.0"
|
|
22
26
|
},
|
|
23
27
|
"devDependencies": {
|
|
24
|
-
"@opencode
|
|
25
|
-
"@opentui/core": "^0.4.2",
|
|
26
|
-
"@opentui/keymap": "^0.4.2",
|
|
27
|
-
"@opentui/solid": "^0.4.2",
|
|
28
|
+
"@opencode/theme": "2.0.10",
|
|
28
29
|
"@types/bun": "^1.3.14",
|
|
29
|
-
"
|
|
30
|
+
"@types/node": "^24.0.0",
|
|
31
|
+
"typescript": "^5.9.0"
|
|
30
32
|
},
|
|
31
33
|
"files": [
|
|
32
34
|
"assets/",
|
|
33
35
|
"docs/",
|
|
34
36
|
"src/",
|
|
35
37
|
"README.md",
|
|
36
|
-
"tui.
|
|
37
|
-
"
|
|
38
|
+
"tui.tsx",
|
|
39
|
+
"view.tsx"
|
|
38
40
|
]
|
|
39
41
|
}
|
|
@@ -1,14 +1,38 @@
|
|
|
1
|
-
import type { RGBA } from "@opentui/core"
|
|
2
|
-
import type { TuiPromptInfo, TuiPromptRef } from "@opencode-ai/plugin/tui"
|
|
3
|
-
import type { PromptContext } from "../../prompt/types"
|
|
1
|
+
import type { CursorStyleOptions, LineInfo, RGBA } from "@opentui/core"
|
|
4
2
|
import type { VimCursorStyle } from "./config"
|
|
5
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
|
+
|
|
6
27
|
export type EditBufferLike = {
|
|
28
|
+
isDestroyed?: boolean
|
|
29
|
+
width?: number
|
|
30
|
+
wrapMode?: string
|
|
7
31
|
cursorOffset?: number
|
|
8
32
|
plainText?: string
|
|
9
33
|
visualCursor?: VisualCursorLike
|
|
10
|
-
editorView?: { getVisualEOL?: () => VisualCursorLike | undefined; setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void; resetSelection?: () => void }
|
|
11
|
-
cursorStyle?:
|
|
34
|
+
editorView?: { getLogicalLineInfo?: () => LineInfo; getVisualEOL?: () => VisualCursorLike | undefined; setSelection?: (start: number, end: number, bgColor?: RGBA, fgColor?: RGBA) => void; resetSelection?: () => void }
|
|
35
|
+
cursorStyle?: CursorStyleOptions
|
|
12
36
|
selectionBg?: RGBA
|
|
13
37
|
selectionFg?: RGBA
|
|
14
38
|
moveCursorLeft?: () => boolean
|
|
@@ -16,6 +40,7 @@ export type EditBufferLike = {
|
|
|
16
40
|
moveCursorUp?: () => boolean
|
|
17
41
|
moveCursorDown?: () => boolean
|
|
18
42
|
setSelection?: (start: number, end: number) => void
|
|
43
|
+
insertText?: (text: string) => void
|
|
19
44
|
setSelectionInclusive?: (start: number, end: number) => void
|
|
20
45
|
clearSelection?: () => void
|
|
21
46
|
gotoVisualLineEnd?: () => boolean
|
|
@@ -43,7 +68,7 @@ export function focusedInput(ctx: PromptContext): EditBufferLike | undefined {
|
|
|
43
68
|
return focused
|
|
44
69
|
}
|
|
45
70
|
|
|
46
|
-
export function setInput(ref:
|
|
71
|
+
export function setInput(ref: PromptRef, input: string) {
|
|
47
72
|
ref.set(toPromptInfo(ref, input))
|
|
48
73
|
}
|
|
49
74
|
|
|
@@ -51,7 +76,7 @@ function hasEditBufferMethods(input: EditBufferLike) {
|
|
|
51
76
|
return typeof input.moveCursorLeft === "function" || typeof input.moveCursorRight === "function" || typeof input.moveCursorUp === "function" || typeof input.moveCursorDown === "function" || typeof input.gotoLineEnd === "function"
|
|
52
77
|
}
|
|
53
78
|
|
|
54
|
-
function toPromptInfo(ref:
|
|
79
|
+
function toPromptInfo(ref: PromptRef, input: string): PromptInfo {
|
|
55
80
|
return {
|
|
56
81
|
input,
|
|
57
82
|
mode: ref.current.mode,
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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>
|
package/src/modules/vim/keys.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { KeyEvent } from "@opentui/core"
|
|
2
2
|
|
|
3
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
4
|
+
|
|
3
5
|
const NAMED_KEYS: Record<string, string> = {
|
|
4
6
|
escape: "<Esc>",
|
|
5
7
|
esc: "<Esc>",
|
|
@@ -15,6 +17,8 @@ const NAMED_KEYS: Record<string, string> = {
|
|
|
15
17
|
right: "<Right>",
|
|
16
18
|
home: "<Home>",
|
|
17
19
|
end: "<End>",
|
|
20
|
+
pageup: "<PageUp>",
|
|
21
|
+
pagedown: "<PageDown>",
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export function keyNotation(event: KeyEvent) {
|
|
@@ -23,7 +27,7 @@ export function keyNotation(event: KeyEvent) {
|
|
|
23
27
|
|
|
24
28
|
if (event.ctrl) return `<C-${ctrlKey(name)}>`
|
|
25
29
|
if (event.meta) return `<M-${name}>`
|
|
26
|
-
if (name.length === 1) return event.shift ? name.toUpperCase() : name
|
|
30
|
+
if ([...graphemes.segment(name)].length === 1) return event.shift ? name.toUpperCase() : name
|
|
27
31
|
return NAMED_KEYS[name] ?? `<${name}>`
|
|
28
32
|
}
|
|
29
33
|
|
package/src/modules/vim/map.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { CursorPosition } from "@vimee/core"
|
|
2
2
|
import type { EditBufferLike } from "./actions"
|
|
3
|
+
import { createGraphemeCodec, type GraphemeCodec } from "./graphemes"
|
|
3
4
|
|
|
4
5
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
5
6
|
|
|
6
7
|
function graphemeWidth(value: string) {
|
|
7
8
|
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
|
|
9
|
+
// OpenTUI's edit buffer gives tabs a fixed two-column width by default.
|
|
10
|
+
if (value === "\t") return 2
|
|
8
11
|
return value === "\n" ? 1 : Bun.stringWidth(value)
|
|
9
12
|
}
|
|
10
13
|
|
|
@@ -33,14 +36,15 @@ export function displayWidth(text: string): number {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export type PromptMap = {
|
|
39
|
+
codec: GraphemeCodec
|
|
36
40
|
hostText: string
|
|
37
41
|
vimText: string
|
|
38
42
|
hostToVim: number[]
|
|
39
43
|
vimToHost: Array<number | undefined>
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
export function createPromptMap(hostText: string, input?: EditBufferLike): PromptMap {
|
|
43
|
-
return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [])
|
|
46
|
+
export function createPromptMap(hostText: string, input?: EditBufferLike, codec = createGraphemeCodec()): PromptMap {
|
|
47
|
+
return buildPromptMap(hostText, input ? visualWrapOffsets(input, hostText) : [], codec)
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
|
|
@@ -57,10 +61,10 @@ export function derivePromptMap(map: PromptMap, vimText: string): PromptMap {
|
|
|
57
61
|
continue
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
hostText += vimText[vimOffset]
|
|
64
|
+
hostText += map.codec.decode(vimText[vimOffset])
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
return buildPromptMapFromSynthetic(hostText, vimText, synthetic)
|
|
67
|
+
return buildPromptMapFromSynthetic(hostText, vimText, synthetic, map.codec)
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
function preserveSynthetic(vimOffset: number, prefix: number, suffix: number, vimLength: number, inserted: string) {
|
|
@@ -78,30 +82,30 @@ export function hostOffset(map: PromptMap, position: CursorPosition, bias: "prev
|
|
|
78
82
|
return charToDisplay(map.hostText, charIdx)
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
function buildPromptMap(hostText: string, wraps: number[]): PromptMap {
|
|
85
|
+
function buildPromptMap(hostText: string, wraps: number[], codec: GraphemeCodec): PromptMap {
|
|
82
86
|
const hostToVim: number[] = []
|
|
83
87
|
const vimToHost: Array<number | undefined> = []
|
|
84
88
|
const wrapOffsets = new Set(wraps.filter((offset) => offset > 0 && offset < hostText.length && hostText[offset - 1] !== "\n"))
|
|
85
89
|
let vimText = ""
|
|
86
90
|
let vimOffset = 0
|
|
87
91
|
|
|
88
|
-
for (
|
|
92
|
+
for (const { index: hostOffset, segment } of graphemes.segment(hostText)) {
|
|
89
93
|
if (wrapOffsets.has(hostOffset)) {
|
|
90
94
|
vimText += "\n"
|
|
91
95
|
vimToHost[vimOffset] = undefined
|
|
92
96
|
vimOffset++
|
|
93
97
|
}
|
|
94
|
-
hostToVim[
|
|
95
|
-
vimText +=
|
|
98
|
+
for (let index = hostOffset; index < hostOffset + segment.length; index++) hostToVim[index] = vimOffset
|
|
99
|
+
vimText += codec.encode(segment)
|
|
96
100
|
vimToHost[vimOffset] = hostOffset
|
|
97
101
|
vimOffset++
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
hostToVim[hostText.length] = vimOffset
|
|
101
|
-
return { hostText, vimText, hostToVim, vimToHost }
|
|
105
|
+
return { hostText, vimText, hostToVim, vimToHost, codec }
|
|
102
106
|
}
|
|
103
107
|
|
|
104
|
-
function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number
|
|
108
|
+
function buildPromptMapFromSynthetic(hostText: string, vimText: string, synthetic: Set<number>, codec: GraphemeCodec): PromptMap {
|
|
105
109
|
const hostToVim: number[] = []
|
|
106
110
|
const vimToHost: Array<number | undefined> = []
|
|
107
111
|
let hostOffset = 0
|
|
@@ -112,13 +116,13 @@ function buildPromptMapFromSynthetic(hostText: string, vimText: string, syntheti
|
|
|
112
116
|
continue
|
|
113
117
|
}
|
|
114
118
|
|
|
115
|
-
hostToVim[hostOffset] = vimOffset
|
|
116
119
|
vimToHost[vimOffset] = hostOffset
|
|
117
|
-
|
|
120
|
+
const length = codec.decode(vimText[vimOffset]).length
|
|
121
|
+
for (let index = 0; index < length; index++) hostToVim[hostOffset++] = vimOffset
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
hostToVim[hostText.length] = vimText.length
|
|
121
|
-
return { hostText, vimText, hostToVim, vimToHost }
|
|
125
|
+
return { hostText, vimText, hostToVim, vimToHost, codec }
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, prefix: number, suffix: number) {
|
|
@@ -128,22 +132,18 @@ function previousOffset(map: PromptMap, vimOffset: number, vimLength: number, pr
|
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
function visualWrapOffsets(input: EditBufferLike, text: string) {
|
|
131
|
-
const original = input.cursorOffset
|
|
132
135
|
const wraps: number[] = []
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
const lines = input.editorView?.getLogicalLineInfo?.()
|
|
137
|
+
if (!lines) return wraps
|
|
138
|
+
let line = 1
|
|
139
|
+
let offset = 0
|
|
140
|
+
for (const part of graphemes.segment(text)) {
|
|
141
|
+
while (line < lines.lineStartCols.length && lines.lineStartCols[line] <= offset) {
|
|
142
|
+
if (lines.lineWraps[line] > 0) wraps.push(part.index)
|
|
143
|
+
line++
|
|
141
144
|
}
|
|
142
|
-
|
|
143
|
-
previousRow = row
|
|
145
|
+
offset += graphemeWidth(part.segment)
|
|
144
146
|
}
|
|
145
|
-
|
|
146
|
-
input.cursorOffset = original
|
|
147
147
|
return wraps
|
|
148
148
|
}
|
|
149
149
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
2
|
import type { KeyEvent } from "@opentui/core"
|
|
3
|
-
import type { PromptContext } from "
|
|
3
|
+
import type { PromptContext } from "./actions"
|
|
4
4
|
import { createVimConfig } from "./config"
|
|
5
5
|
import type { VimLog } from "./log"
|
|
6
6
|
import { createVimState, type VimMode } from "./state"
|