pi-vim 0.3.2 → 0.8.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/README.md +188 -169
- package/clipboard-policy.ts +73 -0
- package/index.ts +1012 -183
- package/motions.ts +14 -4
- package/package.json +7 -3
- package/text-objects.ts +303 -0
- package/word-boundary-cache.ts +7 -7
package/motions.ts
CHANGED
|
@@ -175,7 +175,8 @@ export function findCharMotionTarget(
|
|
|
175
175
|
const nextIndex = currentIndex + 1 + tillRepeatOffset;
|
|
176
176
|
let found = -1;
|
|
177
177
|
for (let j = nextIndex; j < graphemes.length; j++) {
|
|
178
|
-
const g = graphemes[j]
|
|
178
|
+
const g = graphemes[j];
|
|
179
|
+
if (!g) continue;
|
|
179
180
|
// Use startsWith to allow matching base chars if targetChar lacks combining marks,
|
|
180
181
|
// or just exact match since targetChar is typically a full grapheme.
|
|
181
182
|
if (line.slice(g.start, g.end) === targetChar || line.slice(g.start, g.end).startsWith(targetChar)) {
|
|
@@ -184,13 +185,20 @@ export function findCharMotionTarget(
|
|
|
184
185
|
}
|
|
185
186
|
}
|
|
186
187
|
if (found === -1) return null;
|
|
187
|
-
if (isFinal)
|
|
188
|
+
if (isFinal) {
|
|
189
|
+
const targetGrapheme = graphemes[found];
|
|
190
|
+
if (!targetGrapheme) return null;
|
|
191
|
+
if (!isTill) return targetGrapheme.start;
|
|
192
|
+
const previousGrapheme = graphemes[found - 1];
|
|
193
|
+
return previousGrapheme ? previousGrapheme.start : null;
|
|
194
|
+
}
|
|
188
195
|
currentIndex = found;
|
|
189
196
|
} else {
|
|
190
197
|
const nextIndex = currentIndex - 1 - tillRepeatOffset;
|
|
191
198
|
let found = -1;
|
|
192
199
|
for (let j = nextIndex; j >= 0; j--) {
|
|
193
|
-
const g = graphemes[j]
|
|
200
|
+
const g = graphemes[j];
|
|
201
|
+
if (!g) continue;
|
|
194
202
|
if (line.slice(g.start, g.end) === targetChar || line.slice(g.start, g.end).startsWith(targetChar)) {
|
|
195
203
|
found = j;
|
|
196
204
|
break;
|
|
@@ -198,7 +206,9 @@ export function findCharMotionTarget(
|
|
|
198
206
|
}
|
|
199
207
|
if (found === -1) return null;
|
|
200
208
|
if (isFinal) {
|
|
201
|
-
|
|
209
|
+
const targetGrapheme = graphemes[found];
|
|
210
|
+
if (!targetGrapheme) return null;
|
|
211
|
+
if (!isTill) return targetGrapheme.start;
|
|
202
212
|
const afterTarget = graphemes[found + 1];
|
|
203
213
|
return afterTarget ? afterTarget.start : line.length;
|
|
204
214
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vim",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Vim-style modal editing for Pi's TUI editor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -22,11 +22,13 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "echo 'nothing to build'",
|
|
25
|
-
"
|
|
25
|
+
"format": "biome format --write .",
|
|
26
|
+
"lint": "biome lint . && eslint .",
|
|
26
27
|
"typecheck": "tsc --noEmit",
|
|
27
28
|
"test": "node --import tsx/esm --test 'test/**/*.test.ts'",
|
|
28
29
|
"check": "npm run lint && npm run typecheck && npm run test",
|
|
29
30
|
"pack:check": "node --import tsx/esm script/pack-check.ts",
|
|
31
|
+
"hooks:install": "lefthook install",
|
|
30
32
|
"prepublishOnly": "npm run lint && npm run typecheck && npm run pack:check && npm test"
|
|
31
33
|
},
|
|
32
34
|
"pi": {
|
|
@@ -35,15 +37,17 @@
|
|
|
35
37
|
]
|
|
36
38
|
},
|
|
37
39
|
"devDependencies": {
|
|
40
|
+
"@biomejs/biome": "2.4.8",
|
|
38
41
|
"@eslint/js": "^9.25.1",
|
|
39
42
|
"@types/node": "^24.7.2",
|
|
40
43
|
"eslint": "^9.25.1",
|
|
44
|
+
"lefthook": "^2.1.4",
|
|
41
45
|
"tsx": "^4.19.3",
|
|
42
46
|
"typescript": "^5.9.3",
|
|
43
47
|
"typescript-eslint": "^8.31.1"
|
|
44
48
|
},
|
|
45
49
|
"peerDependencies": {
|
|
46
50
|
"@mariozechner/pi-coding-agent": "*",
|
|
47
|
-
"@mariozechner/pi-tui": "
|
|
51
|
+
"@mariozechner/pi-tui": ">=0.47.0"
|
|
48
52
|
}
|
|
49
53
|
}
|
package/text-objects.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
export type TextObjectKind = "i" | "a";
|
|
2
|
+
|
|
3
|
+
export type TextObjectRange = {
|
|
4
|
+
startAbs: number;
|
|
5
|
+
endAbs: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type WordTextObjectClass = "word" | "WORD";
|
|
9
|
+
|
|
10
|
+
export type DelimiterSpec = {
|
|
11
|
+
type: "quote" | "bracket";
|
|
12
|
+
open: string;
|
|
13
|
+
close: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function normalizeCount(count: number): number {
|
|
17
|
+
if (!Number.isFinite(count) || count < 1) return 1;
|
|
18
|
+
return Math.floor(count);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clampCursorCol(line: string, cursorCol: number): number {
|
|
22
|
+
if (line.length === 0) return 0;
|
|
23
|
+
if (!Number.isFinite(cursorCol)) return 0;
|
|
24
|
+
|
|
25
|
+
const normalized = Math.trunc(cursorCol);
|
|
26
|
+
return Math.max(0, Math.min(normalized, line.length - 1));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clampCursorAbs(text: string, cursorAbs: number): number {
|
|
30
|
+
if (text.length === 0) return 0;
|
|
31
|
+
if (!Number.isFinite(cursorAbs)) return 0;
|
|
32
|
+
|
|
33
|
+
const normalized = Math.trunc(cursorAbs);
|
|
34
|
+
return Math.max(0, Math.min(normalized, text.length - 1));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findLogicalLineBounds(line: string, cursorCol: number): { start: number; end: number } {
|
|
38
|
+
if (line.length === 0) return { start: 0, end: 0 };
|
|
39
|
+
|
|
40
|
+
const previousSearchStart = line[cursorCol] === "\n" ? cursorCol - 1 : cursorCol;
|
|
41
|
+
const start = line.lastIndexOf("\n", previousSearchStart) + 1;
|
|
42
|
+
const nextNewline = line.indexOf("\n", cursorCol);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
start,
|
|
46
|
+
end: nextNewline === -1 ? line.length : nextNewline,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findCurrentLineBounds(text: string, cursorAbs: number): { startAbs: number; endAbs: number } {
|
|
51
|
+
const cursor = clampCursorAbs(text, cursorAbs);
|
|
52
|
+
const bounds = findLogicalLineBounds(text, cursor);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
startAbs: bounds.start,
|
|
56
|
+
endAbs: bounds.end,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isWordTextObjectChar(ch: string | undefined, semanticClass: WordTextObjectClass): boolean {
|
|
61
|
+
if (ch === undefined) return false;
|
|
62
|
+
if (semanticClass === "WORD") return !/\s/.test(ch);
|
|
63
|
+
return /\w/.test(ch);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isWhitespace(ch: string | undefined): boolean {
|
|
67
|
+
return ch !== undefined && /\s/.test(ch);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizeDelimiterKey(key: string): DelimiterSpec | null {
|
|
71
|
+
if (key === "\"" || key === "'" || key === "`") {
|
|
72
|
+
return {
|
|
73
|
+
type: "quote",
|
|
74
|
+
open: key,
|
|
75
|
+
close: key,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (key === "(" || key === ")" || key === "b") {
|
|
80
|
+
return {
|
|
81
|
+
type: "bracket",
|
|
82
|
+
open: "(",
|
|
83
|
+
close: ")",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (key === "[" || key === "]") {
|
|
88
|
+
return {
|
|
89
|
+
type: "bracket",
|
|
90
|
+
open: "[",
|
|
91
|
+
close: "]",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (key === "{" || key === "}" || key === "B") {
|
|
96
|
+
return {
|
|
97
|
+
type: "bracket",
|
|
98
|
+
open: "{",
|
|
99
|
+
close: "}",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function isEscapedDelimiter(text: string, index: number): boolean {
|
|
107
|
+
if (!Number.isInteger(index) || index <= 0 || index >= text.length) return false;
|
|
108
|
+
|
|
109
|
+
let backslashCount = 0;
|
|
110
|
+
for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) {
|
|
111
|
+
backslashCount++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return backslashCount % 2 === 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resolveQuoteObjectRange(
|
|
118
|
+
text: string,
|
|
119
|
+
cursorAbs: number,
|
|
120
|
+
kind: TextObjectKind,
|
|
121
|
+
quote: string,
|
|
122
|
+
): TextObjectRange | null {
|
|
123
|
+
const spec = normalizeDelimiterKey(quote);
|
|
124
|
+
if (spec?.type !== "quote") return null;
|
|
125
|
+
|
|
126
|
+
const cursor = clampCursorAbs(text, cursorAbs);
|
|
127
|
+
const bounds = findCurrentLineBounds(text, cursor);
|
|
128
|
+
if (bounds.startAbs >= bounds.endAbs) return null;
|
|
129
|
+
|
|
130
|
+
let openIndex: number | null = null;
|
|
131
|
+
let bestPair: { open: number; close: number } | null = null;
|
|
132
|
+
|
|
133
|
+
for (let index = bounds.startAbs; index < bounds.endAbs; index++) {
|
|
134
|
+
if (text[index] !== quote || isEscapedDelimiter(text, index)) continue;
|
|
135
|
+
|
|
136
|
+
if (openIndex === null) {
|
|
137
|
+
openIndex = index;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const closeIndex = index;
|
|
142
|
+
if (openIndex <= cursor && cursor <= closeIndex) {
|
|
143
|
+
if (bestPair === null || closeIndex - openIndex < bestPair.close - bestPair.open) {
|
|
144
|
+
bestPair = { open: openIndex, close: closeIndex };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
openIndex = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (bestPair === null) return null;
|
|
151
|
+
|
|
152
|
+
if (kind === "i") {
|
|
153
|
+
return {
|
|
154
|
+
startAbs: bestPair.open + 1,
|
|
155
|
+
endAbs: bestPair.close,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
startAbs: bestPair.open,
|
|
161
|
+
endAbs: bestPair.close + 1,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function resolveBracketObjectRange(
|
|
166
|
+
text: string,
|
|
167
|
+
cursorAbs: number,
|
|
168
|
+
kind: TextObjectKind,
|
|
169
|
+
open: string,
|
|
170
|
+
close: string,
|
|
171
|
+
): TextObjectRange | null {
|
|
172
|
+
if (open.length !== 1 || close.length !== 1 || open === close) return null;
|
|
173
|
+
|
|
174
|
+
const cursor = clampCursorAbs(text, cursorAbs);
|
|
175
|
+
const openStack: number[] = [];
|
|
176
|
+
let bestPair: { open: number; close: number } | null = null;
|
|
177
|
+
|
|
178
|
+
for (let index = 0; index < text.length; index++) {
|
|
179
|
+
const ch = text[index];
|
|
180
|
+
|
|
181
|
+
if (ch === open) {
|
|
182
|
+
openStack.push(index);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (ch !== close) continue;
|
|
187
|
+
|
|
188
|
+
const openIndex = openStack.pop();
|
|
189
|
+
if (openIndex === undefined) continue;
|
|
190
|
+
|
|
191
|
+
if (openIndex <= cursor && cursor <= index) {
|
|
192
|
+
if (bestPair === null || index - openIndex < bestPair.close - bestPair.open) {
|
|
193
|
+
bestPair = { open: openIndex, close: index };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (bestPair === null) return null;
|
|
199
|
+
|
|
200
|
+
if (kind === "i") {
|
|
201
|
+
return {
|
|
202
|
+
startAbs: bestPair.open + 1,
|
|
203
|
+
endAbs: bestPair.close,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
startAbs: bestPair.open,
|
|
209
|
+
endAbs: bestPair.close + 1,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function resolveDelimitedTextObjectRange(
|
|
214
|
+
text: string,
|
|
215
|
+
cursorAbs: number,
|
|
216
|
+
kind: TextObjectKind,
|
|
217
|
+
key: string,
|
|
218
|
+
): TextObjectRange | null {
|
|
219
|
+
const spec = normalizeDelimiterKey(key);
|
|
220
|
+
if (spec === null) return null;
|
|
221
|
+
|
|
222
|
+
if (spec.type === "quote") {
|
|
223
|
+
return resolveQuoteObjectRange(text, cursorAbs, kind, spec.open);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (spec.type === "bracket") {
|
|
227
|
+
return resolveBracketObjectRange(text, cursorAbs, kind, spec.open, spec.close);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function resolveWordTextObjectRange(
|
|
234
|
+
line: string,
|
|
235
|
+
lineStartAbs: number,
|
|
236
|
+
cursorCol: number,
|
|
237
|
+
kind: TextObjectKind,
|
|
238
|
+
count: number = 1,
|
|
239
|
+
semanticClass: WordTextObjectClass = "word",
|
|
240
|
+
): TextObjectRange | null {
|
|
241
|
+
if (line.length === 0) return null;
|
|
242
|
+
|
|
243
|
+
const cursor = clampCursorCol(line, cursorCol);
|
|
244
|
+
const bounds = findLogicalLineBounds(line, cursor);
|
|
245
|
+
if (bounds.start >= bounds.end) return null;
|
|
246
|
+
|
|
247
|
+
const hasWordChar = (idx: number) => (
|
|
248
|
+
idx >= bounds.start
|
|
249
|
+
&& idx < bounds.end
|
|
250
|
+
&& isWordTextObjectChar(line[idx], semanticClass)
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
let col = Math.max(bounds.start, Math.min(cursor, bounds.end - 1));
|
|
254
|
+
|
|
255
|
+
if (!hasWordChar(col)) {
|
|
256
|
+
let right = col;
|
|
257
|
+
while (right < bounds.end && !hasWordChar(right)) right++;
|
|
258
|
+
|
|
259
|
+
if (right < bounds.end) {
|
|
260
|
+
col = right;
|
|
261
|
+
} else {
|
|
262
|
+
let left = Math.min(col, bounds.end - 1);
|
|
263
|
+
while (left >= bounds.start && !hasWordChar(left)) left--;
|
|
264
|
+
if (left < bounds.start) return null;
|
|
265
|
+
col = left;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let start = col;
|
|
270
|
+
while (start > bounds.start && hasWordChar(start - 1)) start--;
|
|
271
|
+
|
|
272
|
+
let end = col + 1;
|
|
273
|
+
while (end < bounds.end && hasWordChar(end)) end++;
|
|
274
|
+
|
|
275
|
+
let remaining = normalizeCount(count) - 1;
|
|
276
|
+
while (remaining > 0) {
|
|
277
|
+
let nextWordStart = end;
|
|
278
|
+
while (nextWordStart < bounds.end && !hasWordChar(nextWordStart)) nextWordStart++;
|
|
279
|
+
if (nextWordStart >= bounds.end) break;
|
|
280
|
+
|
|
281
|
+
let nextWordEnd = nextWordStart + 1;
|
|
282
|
+
while (nextWordEnd < bounds.end && hasWordChar(nextWordEnd)) nextWordEnd++;
|
|
283
|
+
|
|
284
|
+
end = nextWordEnd;
|
|
285
|
+
remaining--;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (kind === "a") {
|
|
289
|
+
let aroundEnd = end;
|
|
290
|
+
while (aroundEnd < bounds.end && isWhitespace(line[aroundEnd])) aroundEnd++;
|
|
291
|
+
|
|
292
|
+
if (aroundEnd > end) {
|
|
293
|
+
end = aroundEnd;
|
|
294
|
+
} else {
|
|
295
|
+
while (start > bounds.start && isWhitespace(line[start - 1])) start--;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
startAbs: lineStartAbs + start,
|
|
301
|
+
endAbs: lineStartAbs + end,
|
|
302
|
+
};
|
|
303
|
+
}
|
package/word-boundary-cache.ts
CHANGED
|
@@ -53,7 +53,7 @@ function buildWordBoundaryData(
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
for (let runStart = 0; runStart < len;) {
|
|
56
|
-
const runType = charTypes[runStart]
|
|
56
|
+
const runType = charTypes[runStart] ?? CharType.Space;
|
|
57
57
|
let runEnd = runStart;
|
|
58
58
|
while (runEnd + 1 < len && charTypes[runEnd + 1] === runType) {
|
|
59
59
|
runEnd++;
|
|
@@ -109,13 +109,13 @@ function findTargetInLine(
|
|
|
109
109
|
|
|
110
110
|
if (target === "start") {
|
|
111
111
|
if (data.charTypes[i] !== CharType.Space) {
|
|
112
|
-
i = data.runEndByIndex[i]
|
|
112
|
+
i = (data.runEndByIndex[i] ?? i) + 1;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
if (i >= len) return len;
|
|
116
116
|
|
|
117
117
|
if (data.charTypes[i] === CharType.Space) {
|
|
118
|
-
const next = data.nextNonSpaceAtOrAfter[i]
|
|
118
|
+
const next = data.nextNonSpaceAtOrAfter[i] ?? -1;
|
|
119
119
|
return next === -1 ? len : next;
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -127,23 +127,23 @@ function findTargetInLine(
|
|
|
127
127
|
if (i >= len) return len;
|
|
128
128
|
|
|
129
129
|
if (data.charTypes[i] === CharType.Space) {
|
|
130
|
-
const next = data.nextNonSpaceAtOrAfter[i]
|
|
130
|
+
const next = data.nextNonSpaceAtOrAfter[i] ?? -1;
|
|
131
131
|
if (next === -1) return len;
|
|
132
132
|
i = next;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
return data.runEndByIndex[i]
|
|
135
|
+
return data.runEndByIndex[i] ?? i;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
if (i >= len) i = len - 1;
|
|
139
139
|
if (i > 0) i--;
|
|
140
140
|
|
|
141
141
|
if (data.charTypes[i] === CharType.Space) {
|
|
142
|
-
const prev = data.prevNonSpaceAtOrBefore[i]
|
|
142
|
+
const prev = data.prevNonSpaceAtOrBefore[i] ?? -1;
|
|
143
143
|
if (prev !== -1) i = prev;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
return data.runStartByIndex[i]
|
|
146
|
+
return data.runStartByIndex[i] ?? i;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
const DEFAULT_MAX_CACHE_ENTRIES = 256;
|