pi-vim 0.1.0

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/motions.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Motion calculation utilities for vim-mode
3
+ */
4
+
5
+ import type { CharMotion } from "./types.js";
6
+
7
+ // Character types for word boundary detection
8
+ enum CharType {
9
+ Space = 0,
10
+ Keyword = 1, // alphanumeric + underscore
11
+ Other = 2, // punctuation/symbols
12
+ }
13
+
14
+ function getCharType(c: string | undefined): CharType {
15
+ if (!c || /\s/.test(c)) return CharType.Space;
16
+ if (/\w/.test(c)) return CharType.Keyword;
17
+ return CharType.Other;
18
+ }
19
+
20
+ /**
21
+ * Reverse a character motion direction (f ↔ F, t ↔ T).
22
+ */
23
+ export function reverseCharMotion(motion: CharMotion): CharMotion {
24
+ const reverseMap: Record<CharMotion, CharMotion> = {
25
+ f: "F",
26
+ F: "f",
27
+ t: "T",
28
+ T: "t",
29
+ };
30
+ return reverseMap[motion];
31
+ }
32
+
33
+ /**
34
+ * Find target column for a character motion (f/F/t/T).
35
+ * @returns target column or null if not found
36
+ */
37
+ export function findCharMotionTarget(
38
+ line: string,
39
+ col: number,
40
+ motion: CharMotion,
41
+ targetChar: string,
42
+ isRepeat: boolean = false,
43
+ ): number | null {
44
+ const isForward = motion === "f" || motion === "t";
45
+ const isTill = motion === "t" || motion === "T";
46
+
47
+ // For till repeats (;/,), we need extra offset to skip past the character we stopped before/after
48
+ const tillRepeatOffset = isTill && isRepeat ? 1 : 0;
49
+
50
+ if (isForward) {
51
+ const searchStart = col + 1 + tillRepeatOffset;
52
+ const idx = line.indexOf(targetChar, searchStart);
53
+ if (idx !== -1) {
54
+ return isTill ? idx - 1 : idx;
55
+ }
56
+ } else {
57
+ const searchStart = col - 1 - tillRepeatOffset;
58
+ const idx = line.lastIndexOf(targetChar, searchStart);
59
+ if (idx !== -1) {
60
+ return isTill ? idx + 1 : idx;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Calculate word motion target column.
68
+ */
69
+ export function findWordMotionTarget(
70
+ line: string,
71
+ col: number,
72
+ direction: "forward" | "backward",
73
+ target: "start" | "end",
74
+ ): number {
75
+ const len = line.length;
76
+ if (len === 0) return 0;
77
+
78
+ let i = Math.max(0, Math.min(col, len));
79
+
80
+ if (direction === "forward") {
81
+ if (i >= len) return len;
82
+
83
+ if (target === "start") {
84
+ // w: move to start of next word
85
+ const startType = getCharType(line[i]);
86
+
87
+ // Skip current word/punct block
88
+ if (startType !== CharType.Space) {
89
+ while (i < len && getCharType(line[i]) === startType) i++;
90
+ }
91
+
92
+ // Skip whitespace
93
+ while (i < len && getCharType(line[i]) === CharType.Space) i++;
94
+
95
+ return i;
96
+ }
97
+
98
+ // e: move to end of current/next word
99
+ if (i < len - 1) i++;
100
+
101
+ // Skip whitespace forward
102
+ while (i < len && getCharType(line[i]) === CharType.Space) i++;
103
+
104
+ // Now at start of next word (or end of line). Find end.
105
+ if (i >= len) return len;
106
+
107
+ const type = getCharType(line[i]);
108
+ while (i < len - 1 && getCharType(line[i + 1]) === type) i++;
109
+
110
+ return i;
111
+ }
112
+
113
+ // b: move to start of previous word
114
+ if (i >= len) i = len - 1;
115
+ if (i > 0) i--;
116
+
117
+ // Skip whitespace backward
118
+ while (i > 0 && getCharType(line[i]) === CharType.Space) i--;
119
+
120
+ // Now at end of prev word (or start of line). Find start.
121
+ const type = getCharType(line[i]);
122
+ while (i > 0 && getCharType(line[i - 1]) === type) i--;
123
+
124
+ return i;
125
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "pi-vim",
3
+ "version": "0.1.0",
4
+ "description": "Vim-style modal editing for Pi's TUI editor",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "vim",
10
+ "editor"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/lajarre/pi-vim.git"
16
+ },
17
+ "files": [
18
+ "*.ts",
19
+ "!*.test.ts",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "build": "echo 'nothing to build'",
25
+ "test": "node --import tsx/esm --test 'test/**/*.test.ts'",
26
+ "check": "npm run test"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./index.ts"
31
+ ]
32
+ },
33
+ "devDependencies": {
34
+ "tsx": "^4.19.3"
35
+ },
36
+ "peerDependencies": {
37
+ "@mariozechner/pi-coding-agent": "*",
38
+ "@mariozechner/pi-tui": "*"
39
+ }
40
+ }
package/types.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Types and constants for vim-mode extension
3
+ */
4
+
5
+ export type Mode = "normal" | "insert";
6
+ export type CharMotion = "f" | "F" | "t" | "T";
7
+ export type PendingMotion = CharMotion | null;
8
+ export type PendingOperator = "d" | "c" | "y" | null;
9
+
10
+ export interface LastCharMotion {
11
+ motion: CharMotion;
12
+ char: string;
13
+ }
14
+
15
+ // Normal mode key mappings: key -> escape sequence (or null for mode switch)
16
+ export const NORMAL_KEYS: Record<string, string | null> = {
17
+ h: "\x1b[D", // left
18
+ j: "\x1b[B", // down
19
+ k: "\x1b[A", // up
20
+ l: "\x1b[C", // right
21
+ "0": "\x01", // line start
22
+ $: "\x05", // line end
23
+ x: null, // delete char (custom clipboard handling)
24
+ D: null, // delete to end of line (custom clipboard handling)
25
+ C: null, // change to end of line (delete to end + insert mode)
26
+ S: null, // substitute line (delete line content + insert mode)
27
+ s: null, // substitute char (delete char + insert mode)
28
+ i: null, // insert mode
29
+ a: null, // append (insert + right)
30
+ A: null, // append at end of line
31
+ I: null, // insert at start of line
32
+ o: null, // open line below
33
+ O: null, // open line above
34
+ };
35
+
36
+ // Character motion keys that wait for a target character
37
+ export const CHAR_MOTION_KEYS = new Set<string>(["f", "F", "t", "T"]);
38
+
39
+ // Escape sequences
40
+ export const ESC_LEFT = "\x1b[D";
41
+ export const ESC_RIGHT = "\x1b[C";
42
+ export const ESC_DELETE = "\x1b[3~";
43
+ export const CTRL_A = "\x01"; // line start
44
+ export const CTRL_E = "\x05"; // line end
45
+ export const CTRL_K = "\x0b"; // kill to end of line
46
+ export const CTRL_UNDERSCORE = "\x1f"; // ctrl+_ — readline undo
47
+ export const NEWLINE = "\n"; // newline character
48
+ export const ESC_UP = "\x1b[A"; // cursor up
49
+ export const ESC_DOWN = "\x1b[B"; // cursor down
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Line-local cache for Vim word motion boundaries.
3
+ *
4
+ * Keyed by exact line content to avoid stale boundary reuse.
5
+ */
6
+
7
+ export type WordMotionDirection = "forward" | "backward";
8
+ export type WordMotionTarget = "start" | "end";
9
+
10
+ enum CharType {
11
+ Space = 0,
12
+ Word = 1,
13
+ Other = 2,
14
+ }
15
+
16
+ export interface WordBoundaryData {
17
+ readonly length: number;
18
+ readonly charTypes: Uint8Array;
19
+ readonly runStartByIndex: Int32Array;
20
+ readonly runEndByIndex: Int32Array;
21
+ readonly nextNonSpaceAtOrAfter: Int32Array;
22
+ readonly prevNonSpaceAtOrBefore: Int32Array;
23
+ }
24
+
25
+ function getCharType(ch: string | undefined): CharType {
26
+ if (!ch || /\s/.test(ch)) return CharType.Space;
27
+ if (/\w/.test(ch)) return CharType.Word;
28
+ return CharType.Other;
29
+ }
30
+
31
+ function buildWordBoundaryData(line: string): WordBoundaryData {
32
+ const len = line.length;
33
+ const charTypes = new Uint8Array(len);
34
+ const runStartByIndex = new Int32Array(len);
35
+ const runEndByIndex = new Int32Array(len);
36
+ const nextNonSpaceAtOrAfter = new Int32Array(len + 1);
37
+ const prevNonSpaceAtOrBefore = new Int32Array(len);
38
+
39
+ nextNonSpaceAtOrAfter.fill(-1);
40
+ prevNonSpaceAtOrBefore.fill(-1);
41
+
42
+ for (let i = 0; i < len; i++) {
43
+ charTypes[i] = getCharType(line[i]);
44
+ }
45
+
46
+ for (let runStart = 0; runStart < len;) {
47
+ const runType = charTypes[runStart]!;
48
+ let runEnd = runStart;
49
+ while (runEnd + 1 < len && charTypes[runEnd + 1] === runType) {
50
+ runEnd++;
51
+ }
52
+
53
+ for (let i = runStart; i <= runEnd; i++) {
54
+ runStartByIndex[i] = runStart;
55
+ runEndByIndex[i] = runEnd;
56
+ }
57
+
58
+ runStart = runEnd + 1;
59
+ }
60
+
61
+ let nextNonSpace = -1;
62
+ for (let i = len - 1; i >= 0; i--) {
63
+ if (charTypes[i] !== CharType.Space) {
64
+ nextNonSpace = i;
65
+ }
66
+ nextNonSpaceAtOrAfter[i] = nextNonSpace;
67
+ }
68
+
69
+ let prevNonSpace = -1;
70
+ for (let i = 0; i < len; i++) {
71
+ if (charTypes[i] !== CharType.Space) {
72
+ prevNonSpace = i;
73
+ }
74
+ prevNonSpaceAtOrBefore[i] = prevNonSpace;
75
+ }
76
+
77
+ return {
78
+ length: len,
79
+ charTypes,
80
+ runStartByIndex,
81
+ runEndByIndex,
82
+ nextNonSpaceAtOrAfter,
83
+ prevNonSpaceAtOrBefore,
84
+ };
85
+ }
86
+
87
+ function findTargetInLine(
88
+ data: WordBoundaryData,
89
+ col: number,
90
+ direction: WordMotionDirection,
91
+ target: WordMotionTarget,
92
+ ): number {
93
+ const len = data.length;
94
+ if (len === 0) return 0;
95
+
96
+ let i = Math.max(0, Math.min(col, len));
97
+
98
+ if (direction === "forward") {
99
+ if (i >= len) return len;
100
+
101
+ if (target === "start") {
102
+ if (data.charTypes[i] !== CharType.Space) {
103
+ i = data.runEndByIndex[i]! + 1;
104
+ }
105
+
106
+ if (i >= len) return len;
107
+
108
+ if (data.charTypes[i] === CharType.Space) {
109
+ const next = data.nextNonSpaceAtOrAfter[i]!;
110
+ return next === -1 ? len : next;
111
+ }
112
+
113
+ return i;
114
+ }
115
+
116
+ if (i < len - 1) i++;
117
+
118
+ if (i >= len) return len;
119
+
120
+ if (data.charTypes[i] === CharType.Space) {
121
+ const next = data.nextNonSpaceAtOrAfter[i]!;
122
+ if (next === -1) return len;
123
+ i = next;
124
+ }
125
+
126
+ return data.runEndByIndex[i]!;
127
+ }
128
+
129
+ if (i >= len) i = len - 1;
130
+ if (i > 0) i--;
131
+
132
+ if (data.charTypes[i] === CharType.Space) {
133
+ const prev = data.prevNonSpaceAtOrBefore[i]!;
134
+ if (prev !== -1) i = prev;
135
+ }
136
+
137
+ return data.runStartByIndex[i]!;
138
+ }
139
+
140
+ const DEFAULT_MAX_CACHE_ENTRIES = 256;
141
+
142
+ export class WordBoundaryCache {
143
+ private readonly entries = new Map<string, WordBoundaryData>();
144
+ private readonly maxEntries: number;
145
+
146
+ constructor(maxEntries: number = DEFAULT_MAX_CACHE_ENTRIES) {
147
+ this.maxEntries = Number.isInteger(maxEntries) && maxEntries > 0
148
+ ? maxEntries
149
+ : DEFAULT_MAX_CACHE_ENTRIES;
150
+ }
151
+
152
+ get(line: string): WordBoundaryData {
153
+ const cached = this.entries.get(line);
154
+ if (cached) return cached;
155
+
156
+ const built = buildWordBoundaryData(line);
157
+
158
+ if (this.entries.size >= this.maxEntries) {
159
+ const oldestKey = this.entries.keys().next().value;
160
+ if (oldestKey !== undefined) {
161
+ this.entries.delete(oldestKey);
162
+ }
163
+ }
164
+
165
+ this.entries.set(line, built);
166
+ return built;
167
+ }
168
+
169
+ tryFindTarget(
170
+ line: string,
171
+ col: number,
172
+ direction: WordMotionDirection,
173
+ target: WordMotionTarget,
174
+ ): number | null {
175
+ if (!Number.isInteger(col) || col < 0) return null;
176
+
177
+ const boundaries = this.get(line);
178
+ return findTargetInLine(boundaries, col, direction, target);
179
+ }
180
+ }