pi-vim 0.3.2 → 0.9.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 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) return isTill ? graphemes[found - 1]!.start : graphemes[found]!.start;
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
- if (!isTill) return graphemes[found]!.start;
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.2",
3
+ "version": "0.9.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
- "lint": "eslint .",
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
  }
@@ -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
+ }
@@ -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]! + 1;
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;