pi-vim 0.9.0 → 0.10.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 +3 -1
- package/clipboard-policy.ts +7 -57
- package/index.ts +537 -277
- package/motions.ts +38 -15
- package/package.json +3 -3
- package/settings.ts +92 -0
- package/text-objects.ts +38 -15
- package/word-boundary-cache.ts +5 -4
package/motions.ts
CHANGED
|
@@ -49,7 +49,10 @@ export function isBlankLine(line: string | undefined): boolean {
|
|
|
49
49
|
/**
|
|
50
50
|
* Paragraph start: non-blank line at BOF or after a blank line.
|
|
51
51
|
*/
|
|
52
|
-
export function isParagraphStart(
|
|
52
|
+
export function isParagraphStart(
|
|
53
|
+
lines: readonly string[],
|
|
54
|
+
lineIndex: number,
|
|
55
|
+
): boolean {
|
|
53
56
|
if (!Number.isInteger(lineIndex)) return false;
|
|
54
57
|
if (lineIndex < 0 || lineIndex >= lines.length) return false;
|
|
55
58
|
if (isBlankLine(lines[lineIndex])) return false;
|
|
@@ -60,7 +63,10 @@ export function isParagraphStart(lines: readonly string[], lineIndex: number): b
|
|
|
60
63
|
/**
|
|
61
64
|
* One step of } motion from current line index.
|
|
62
65
|
*/
|
|
63
|
-
export function findNextParagraphStart(
|
|
66
|
+
export function findNextParagraphStart(
|
|
67
|
+
lines: readonly string[],
|
|
68
|
+
fromLine: number,
|
|
69
|
+
): number {
|
|
64
70
|
if (lines.length === 0) return 0;
|
|
65
71
|
|
|
66
72
|
const start = clampLineIndex(lines, fromLine) + 1;
|
|
@@ -74,7 +80,10 @@ export function findNextParagraphStart(lines: readonly string[], fromLine: numbe
|
|
|
74
80
|
/**
|
|
75
81
|
* One step of { motion from current line index.
|
|
76
82
|
*/
|
|
77
|
-
export function findPrevParagraphStart(
|
|
83
|
+
export function findPrevParagraphStart(
|
|
84
|
+
lines: readonly string[],
|
|
85
|
+
fromLine: number,
|
|
86
|
+
): number {
|
|
78
87
|
if (lines.length === 0) return 0;
|
|
79
88
|
|
|
80
89
|
const start = clampLineIndex(lines, fromLine) - 1;
|
|
@@ -125,16 +134,21 @@ export function reverseCharMotion(motion: CharMotion): CharMotion {
|
|
|
125
134
|
return reverseMap[motion];
|
|
126
135
|
}
|
|
127
136
|
|
|
128
|
-
const GRAPHEME_SEGMENTER =
|
|
129
|
-
&& typeof Intl.Segmenter === "function"
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
const GRAPHEME_SEGMENTER =
|
|
138
|
+
typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
|
|
139
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
140
|
+
: null;
|
|
132
141
|
|
|
133
|
-
export function getLineGraphemes(
|
|
142
|
+
export function getLineGraphemes(
|
|
143
|
+
line: string,
|
|
144
|
+
): Array<{ start: number; end: number }> {
|
|
134
145
|
const segments: Array<{ start: number; end: number }> = [];
|
|
135
146
|
if (GRAPHEME_SEGMENTER) {
|
|
136
147
|
for (const part of GRAPHEME_SEGMENTER.segment(line)) {
|
|
137
|
-
segments.push({
|
|
148
|
+
segments.push({
|
|
149
|
+
start: part.index,
|
|
150
|
+
end: part.index + part.segment.length,
|
|
151
|
+
});
|
|
138
152
|
}
|
|
139
153
|
return segments;
|
|
140
154
|
}
|
|
@@ -163,7 +177,7 @@ export function findCharMotionTarget(
|
|
|
163
177
|
const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
|
|
164
178
|
|
|
165
179
|
const graphemes = getLineGraphemes(line);
|
|
166
|
-
let currentIndex = graphemes.findIndex(g => col < g.end);
|
|
180
|
+
let currentIndex = graphemes.findIndex((g) => col < g.end);
|
|
167
181
|
if (currentIndex === -1) currentIndex = graphemes.length;
|
|
168
182
|
|
|
169
183
|
for (let i = 0; i < steps; i++) {
|
|
@@ -179,7 +193,10 @@ export function findCharMotionTarget(
|
|
|
179
193
|
if (!g) continue;
|
|
180
194
|
// Use startsWith to allow matching base chars if targetChar lacks combining marks,
|
|
181
195
|
// or just exact match since targetChar is typically a full grapheme.
|
|
182
|
-
if (
|
|
196
|
+
if (
|
|
197
|
+
line.slice(g.start, g.end) === targetChar ||
|
|
198
|
+
line.slice(g.start, g.end).startsWith(targetChar)
|
|
199
|
+
) {
|
|
183
200
|
found = j;
|
|
184
201
|
break;
|
|
185
202
|
}
|
|
@@ -199,7 +216,10 @@ export function findCharMotionTarget(
|
|
|
199
216
|
for (let j = nextIndex; j >= 0; j--) {
|
|
200
217
|
const g = graphemes[j];
|
|
201
218
|
if (!g) continue;
|
|
202
|
-
if (
|
|
219
|
+
if (
|
|
220
|
+
line.slice(g.start, g.end) === targetChar ||
|
|
221
|
+
line.slice(g.start, g.end).startsWith(targetChar)
|
|
222
|
+
) {
|
|
203
223
|
found = j;
|
|
204
224
|
break;
|
|
205
225
|
}
|
|
@@ -243,11 +263,13 @@ export function findWordMotionTarget(
|
|
|
243
263
|
|
|
244
264
|
// Skip current word/punct block
|
|
245
265
|
if (startType !== CharType.Space) {
|
|
246
|
-
while (i < len && getCharType(line[i], semanticClass) === startType)
|
|
266
|
+
while (i < len && getCharType(line[i], semanticClass) === startType)
|
|
267
|
+
i++;
|
|
247
268
|
}
|
|
248
269
|
|
|
249
270
|
// Skip whitespace
|
|
250
|
-
while (i < len && getCharType(line[i], semanticClass) === CharType.Space)
|
|
271
|
+
while (i < len && getCharType(line[i], semanticClass) === CharType.Space)
|
|
272
|
+
i++;
|
|
251
273
|
|
|
252
274
|
return i;
|
|
253
275
|
}
|
|
@@ -256,7 +278,8 @@ export function findWordMotionTarget(
|
|
|
256
278
|
if (i < len - 1) i++;
|
|
257
279
|
|
|
258
280
|
// Skip whitespace forward
|
|
259
|
-
while (i < len && getCharType(line[i], semanticClass) === CharType.Space)
|
|
281
|
+
while (i < len && getCharType(line[i], semanticClass) === CharType.Space)
|
|
282
|
+
i++;
|
|
260
283
|
|
|
261
284
|
// Now at start of next word (or end of line). Find end.
|
|
262
285
|
if (i >= len) return len;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vim",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Vim-style modal editing for Pi's TUI editor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "echo 'nothing to build'",
|
|
25
|
-
"format": "biome
|
|
26
|
-
"lint": "biome
|
|
25
|
+
"format": "biome check --write .",
|
|
26
|
+
"lint": "biome check . && eslint .",
|
|
27
27
|
"typecheck": "tsc --noEmit",
|
|
28
28
|
"test": "node --import tsx/esm --test 'test/**/*.test.ts'",
|
|
29
29
|
"check": "npm run lint && npm run typecheck && npm run test",
|
package/settings.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { SettingsManager } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type ModeColorSettings = {
|
|
4
|
+
insert?: string;
|
|
5
|
+
normal?: string;
|
|
6
|
+
ex?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type PiVimSettings = {
|
|
10
|
+
clipboardMirror?: unknown;
|
|
11
|
+
modeColors?: ModeColorSettings;
|
|
12
|
+
syncBorderColorWithMode?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const M = Symbol(),
|
|
16
|
+
C = ["insert", "normal", "ex"] as const,
|
|
17
|
+
T = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
18
|
+
const rec = (v: unknown): v is Record<string, unknown> =>
|
|
19
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
20
|
+
|
|
21
|
+
function get(s: unknown, k: keyof PiVimSettings): unknown {
|
|
22
|
+
if (!rec(s) || !Object.hasOwn(s, "piVim")) return M;
|
|
23
|
+
const p = s.piVim;
|
|
24
|
+
if (!rec(p)) return p;
|
|
25
|
+
return Object.hasOwn(p, k) ? p[k] : M;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function colors(v: unknown) {
|
|
29
|
+
if (!rec(v)) return;
|
|
30
|
+
const r: ModeColorSettings = {};
|
|
31
|
+
for (const k of C) {
|
|
32
|
+
const x = v[k],
|
|
33
|
+
t = typeof x === "string" ? x.trim() : "";
|
|
34
|
+
if (T.test(t)) r[k] = t;
|
|
35
|
+
}
|
|
36
|
+
return Object.keys(r)[0] ? r : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readPiVimClipboardMirrorSetting(g: unknown, p: unknown) {
|
|
40
|
+
let v = get(p, "clipboardMirror");
|
|
41
|
+
if (v !== M) return v;
|
|
42
|
+
v = get(g, "clipboardMirror");
|
|
43
|
+
return v === M ? undefined : v;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readPiVimModeColors(g: unknown, p: unknown) {
|
|
47
|
+
const v = get(p, "modeColors");
|
|
48
|
+
// Project settings are a whole-setting override. If a project checks in an
|
|
49
|
+
// invalid modeColors value, fall back to pi-vim defaults instead of leaking a
|
|
50
|
+
// developer's global colors into that project.
|
|
51
|
+
if (v !== M) return colors(v);
|
|
52
|
+
const w = get(g, "modeColors");
|
|
53
|
+
return colors(w);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readPiVimBooleanSetting(
|
|
57
|
+
g: unknown,
|
|
58
|
+
p: unknown,
|
|
59
|
+
k: "syncBorderColorWithMode",
|
|
60
|
+
) {
|
|
61
|
+
const v = get(p, k);
|
|
62
|
+
if (v !== M) return typeof v === "boolean" ? v : undefined;
|
|
63
|
+
const w = get(g, k);
|
|
64
|
+
return typeof w === "boolean" ? w : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function disk(cwd: string): PiVimSettings {
|
|
68
|
+
const s = SettingsManager.create(cwd),
|
|
69
|
+
g = s.getGlobalSettings(),
|
|
70
|
+
p = s.getProjectSettings();
|
|
71
|
+
return {
|
|
72
|
+
clipboardMirror: readPiVimClipboardMirrorSetting(g, p),
|
|
73
|
+
modeColors: readPiVimModeColors(g, p),
|
|
74
|
+
syncBorderColorWithMode: readPiVimBooleanSetting(
|
|
75
|
+
g,
|
|
76
|
+
p,
|
|
77
|
+
"syncBorderColorWithMode",
|
|
78
|
+
),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let reader = disk;
|
|
83
|
+
export function readPiVimSettings(cwd: string) {
|
|
84
|
+
return reader(cwd);
|
|
85
|
+
}
|
|
86
|
+
export function setPiVimSettingsReaderForTests(next: typeof disk) {
|
|
87
|
+
const prev = reader;
|
|
88
|
+
reader = next;
|
|
89
|
+
return () => {
|
|
90
|
+
reader = prev;
|
|
91
|
+
};
|
|
92
|
+
}
|
package/text-objects.ts
CHANGED
|
@@ -34,10 +34,14 @@ function clampCursorAbs(text: string, cursorAbs: number): number {
|
|
|
34
34
|
return Math.max(0, Math.min(normalized, text.length - 1));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function findLogicalLineBounds(
|
|
37
|
+
function findLogicalLineBounds(
|
|
38
|
+
line: string,
|
|
39
|
+
cursorCol: number,
|
|
40
|
+
): { start: number; end: number } {
|
|
38
41
|
if (line.length === 0) return { start: 0, end: 0 };
|
|
39
42
|
|
|
40
|
-
const previousSearchStart =
|
|
43
|
+
const previousSearchStart =
|
|
44
|
+
line[cursorCol] === "\n" ? cursorCol - 1 : cursorCol;
|
|
41
45
|
const start = line.lastIndexOf("\n", previousSearchStart) + 1;
|
|
42
46
|
const nextNewline = line.indexOf("\n", cursorCol);
|
|
43
47
|
|
|
@@ -47,7 +51,10 @@ function findLogicalLineBounds(line: string, cursorCol: number): { start: number
|
|
|
47
51
|
};
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
function findCurrentLineBounds(
|
|
54
|
+
function findCurrentLineBounds(
|
|
55
|
+
text: string,
|
|
56
|
+
cursorAbs: number,
|
|
57
|
+
): { startAbs: number; endAbs: number } {
|
|
51
58
|
const cursor = clampCursorAbs(text, cursorAbs);
|
|
52
59
|
const bounds = findLogicalLineBounds(text, cursor);
|
|
53
60
|
|
|
@@ -57,7 +64,10 @@ function findCurrentLineBounds(text: string, cursorAbs: number): { startAbs: num
|
|
|
57
64
|
};
|
|
58
65
|
}
|
|
59
66
|
|
|
60
|
-
function isWordTextObjectChar(
|
|
67
|
+
function isWordTextObjectChar(
|
|
68
|
+
ch: string | undefined,
|
|
69
|
+
semanticClass: WordTextObjectClass,
|
|
70
|
+
): boolean {
|
|
61
71
|
if (ch === undefined) return false;
|
|
62
72
|
if (semanticClass === "WORD") return !/\s/.test(ch);
|
|
63
73
|
return /\w/.test(ch);
|
|
@@ -68,7 +78,7 @@ function isWhitespace(ch: string | undefined): boolean {
|
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
export function normalizeDelimiterKey(key: string): DelimiterSpec | null {
|
|
71
|
-
if (key === "
|
|
81
|
+
if (key === '"' || key === "'" || key === "`") {
|
|
72
82
|
return {
|
|
73
83
|
type: "quote",
|
|
74
84
|
open: key,
|
|
@@ -104,7 +114,8 @@ export function normalizeDelimiterKey(key: string): DelimiterSpec | null {
|
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
export function isEscapedDelimiter(text: string, index: number): boolean {
|
|
107
|
-
if (!Number.isInteger(index) || index <= 0 || index >= text.length)
|
|
117
|
+
if (!Number.isInteger(index) || index <= 0 || index >= text.length)
|
|
118
|
+
return false;
|
|
108
119
|
|
|
109
120
|
let backslashCount = 0;
|
|
110
121
|
for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) {
|
|
@@ -140,7 +151,10 @@ export function resolveQuoteObjectRange(
|
|
|
140
151
|
|
|
141
152
|
const closeIndex = index;
|
|
142
153
|
if (openIndex <= cursor && cursor <= closeIndex) {
|
|
143
|
-
if (
|
|
154
|
+
if (
|
|
155
|
+
bestPair === null ||
|
|
156
|
+
closeIndex - openIndex < bestPair.close - bestPair.open
|
|
157
|
+
) {
|
|
144
158
|
bestPair = { open: openIndex, close: closeIndex };
|
|
145
159
|
}
|
|
146
160
|
}
|
|
@@ -189,7 +203,10 @@ export function resolveBracketObjectRange(
|
|
|
189
203
|
if (openIndex === undefined) continue;
|
|
190
204
|
|
|
191
205
|
if (openIndex <= cursor && cursor <= index) {
|
|
192
|
-
if (
|
|
206
|
+
if (
|
|
207
|
+
bestPair === null ||
|
|
208
|
+
index - openIndex < bestPair.close - bestPair.open
|
|
209
|
+
) {
|
|
193
210
|
bestPair = { open: openIndex, close: index };
|
|
194
211
|
}
|
|
195
212
|
}
|
|
@@ -224,7 +241,13 @@ export function resolveDelimitedTextObjectRange(
|
|
|
224
241
|
}
|
|
225
242
|
|
|
226
243
|
if (spec.type === "bracket") {
|
|
227
|
-
return resolveBracketObjectRange(
|
|
244
|
+
return resolveBracketObjectRange(
|
|
245
|
+
text,
|
|
246
|
+
cursorAbs,
|
|
247
|
+
kind,
|
|
248
|
+
spec.open,
|
|
249
|
+
spec.close,
|
|
250
|
+
);
|
|
228
251
|
}
|
|
229
252
|
|
|
230
253
|
return null;
|
|
@@ -244,11 +267,10 @@ export function resolveWordTextObjectRange(
|
|
|
244
267
|
const bounds = findLogicalLineBounds(line, cursor);
|
|
245
268
|
if (bounds.start >= bounds.end) return null;
|
|
246
269
|
|
|
247
|
-
const hasWordChar = (idx: number) =>
|
|
248
|
-
idx >= bounds.start
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
);
|
|
270
|
+
const hasWordChar = (idx: number) =>
|
|
271
|
+
idx >= bounds.start &&
|
|
272
|
+
idx < bounds.end &&
|
|
273
|
+
isWordTextObjectChar(line[idx], semanticClass);
|
|
252
274
|
|
|
253
275
|
let col = Math.max(bounds.start, Math.min(cursor, bounds.end - 1));
|
|
254
276
|
|
|
@@ -275,7 +297,8 @@ export function resolveWordTextObjectRange(
|
|
|
275
297
|
let remaining = normalizeCount(count) - 1;
|
|
276
298
|
while (remaining > 0) {
|
|
277
299
|
let nextWordStart = end;
|
|
278
|
-
while (nextWordStart < bounds.end && !hasWordChar(nextWordStart))
|
|
300
|
+
while (nextWordStart < bounds.end && !hasWordChar(nextWordStart))
|
|
301
|
+
nextWordStart++;
|
|
279
302
|
if (nextWordStart >= bounds.end) break;
|
|
280
303
|
|
|
281
304
|
let nextWordEnd = nextWordStart + 1;
|
package/word-boundary-cache.ts
CHANGED
|
@@ -52,7 +52,7 @@ function buildWordBoundaryData(
|
|
|
52
52
|
charTypes[i] = getCharType(line[i], semanticClass);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
for (let runStart = 0; runStart < len;) {
|
|
55
|
+
for (let runStart = 0; runStart < len; ) {
|
|
56
56
|
const runType = charTypes[runStart] ?? CharType.Space;
|
|
57
57
|
let runEnd = runStart;
|
|
58
58
|
while (runEnd + 1 < len && charTypes[runEnd + 1] === runType) {
|
|
@@ -153,9 +153,10 @@ export class WordBoundaryCache {
|
|
|
153
153
|
private readonly maxEntries: number;
|
|
154
154
|
|
|
155
155
|
constructor(maxEntries: number = DEFAULT_MAX_CACHE_ENTRIES) {
|
|
156
|
-
this.maxEntries =
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
this.maxEntries =
|
|
157
|
+
Number.isInteger(maxEntries) && maxEntries > 0
|
|
158
|
+
? maxEntries
|
|
159
|
+
: DEFAULT_MAX_CACHE_ENTRIES;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
private makeCacheKey(line: string, semanticClass: WordMotionClass): string {
|