pi-vim 0.9.0 → 0.11.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
@@ -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(lines: readonly string[], lineIndex: number): boolean {
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(lines: readonly string[], fromLine: number): number {
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(lines: readonly string[], fromLine: number): number {
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 = typeof Intl !== "undefined"
129
- && typeof Intl.Segmenter === "function"
130
- ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
131
- : null;
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(line: string): Array<{ start: number; end: number }> {
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({ start: part.index, end: part.index + part.segment.length });
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 (line.slice(g.start, g.end) === targetChar || line.slice(g.start, g.end).startsWith(targetChar)) {
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 (line.slice(g.start, g.end) === targetChar || line.slice(g.start, g.end).startsWith(targetChar)) {
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) i++;
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) i++;
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) i++;
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.9.0",
3
+ "version": "0.11.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 format --write .",
26
- "lint": "biome lint . && eslint .",
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
@@ -13,6 +13,15 @@ export type DelimiterSpec = {
13
13
  close: string;
14
14
  };
15
15
 
16
+ export type MatchingPairKind = "()" | "[]" | "{}";
17
+
18
+ export type MatchingPairMotionTarget = {
19
+ pair: MatchingPairKind;
20
+ sourceAbs: number;
21
+ targetAbs: number;
22
+ rangeAnchorAbs: number;
23
+ };
24
+
16
25
  function normalizeCount(count: number): number {
17
26
  if (!Number.isFinite(count) || count < 1) return 1;
18
27
  return Math.floor(count);
@@ -34,30 +43,24 @@ function clampCursorAbs(text: string, cursorAbs: number): number {
34
43
  return Math.max(0, Math.min(normalized, text.length - 1));
35
44
  }
36
45
 
37
- function findLogicalLineBounds(line: string, cursorCol: number): { start: number; end: number } {
46
+ function findLogicalLineBounds(
47
+ line: string,
48
+ cursorCol: number,
49
+ ): { start: number; end: number } {
38
50
  if (line.length === 0) return { start: 0, end: 0 };
39
51
 
40
- const previousSearchStart = line[cursorCol] === "\n" ? cursorCol - 1 : cursorCol;
52
+ const previousSearchStart =
53
+ line[cursorCol] === "\n" ? cursorCol - 1 : cursorCol;
41
54
  const start = line.lastIndexOf("\n", previousSearchStart) + 1;
42
55
  const nextNewline = line.indexOf("\n", cursorCol);
43
56
 
44
- return {
45
- start,
46
- end: nextNewline === -1 ? line.length : nextNewline,
47
- };
57
+ return { start, end: nextNewline === -1 ? line.length : nextNewline };
48
58
  }
49
59
 
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 {
60
+ function isWordTextObjectChar(
61
+ ch: string | undefined,
62
+ semanticClass: WordTextObjectClass,
63
+ ): boolean {
61
64
  if (ch === undefined) return false;
62
65
  if (semanticClass === "WORD") return !/\s/.test(ch);
63
66
  return /\w/.test(ch);
@@ -67,44 +70,52 @@ function isWhitespace(ch: string | undefined): boolean {
67
70
  return ch !== undefined && /\s/.test(ch);
68
71
  }
69
72
 
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
- }
73
+ function pairKind(ch?: string): MatchingPairKind | null {
74
+ return ch === "(" || ch === ")"
75
+ ? "()"
76
+ : ch === "[" || ch === "]"
77
+ ? "[]"
78
+ : ch === "{" || ch === "}"
79
+ ? "{}"
80
+ : null;
81
+ }
94
82
 
95
- if (key === "{" || key === "}" || key === "B") {
96
- return {
97
- type: "bracket",
98
- open: "{",
99
- close: "}",
100
- };
83
+ function scanSameDelimiterPairs(
84
+ text: string,
85
+ open: string,
86
+ close: string,
87
+ onPair: (openAbs: number, closeAbs: number) => number | null,
88
+ ): number | null {
89
+ const stack: number[] = [];
90
+ for (let index = 0; index < text.length; index++) {
91
+ if (text[index] === open) stack.push(index);
92
+ else if (text[index] === close) {
93
+ const openAbs = stack.pop();
94
+ if (openAbs === undefined) continue;
95
+ const targetAbs = onPair(openAbs, index);
96
+ if (targetAbs !== null) return targetAbs;
97
+ }
101
98
  }
102
-
103
99
  return null;
104
100
  }
105
101
 
102
+ export function normalizeDelimiterKey(key: string): DelimiterSpec | null {
103
+ if (key === '"' || key === "'" || key === "`")
104
+ return { type: "quote", open: key, close: key };
105
+ const pair =
106
+ key === "(" || key === ")" || key === "b"
107
+ ? "()"
108
+ : key === "[" || key === "]"
109
+ ? "[]"
110
+ : key === "{" || key === "}" || key === "B"
111
+ ? "{}"
112
+ : null;
113
+ return pair ? { type: "bracket", open: pair[0], close: pair[1] } : null;
114
+ }
115
+
106
116
  export function isEscapedDelimiter(text: string, index: number): boolean {
107
- if (!Number.isInteger(index) || index <= 0 || index >= text.length) return false;
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--) {
@@ -124,13 +135,13 @@ export function resolveQuoteObjectRange(
124
135
  if (spec?.type !== "quote") return null;
125
136
 
126
137
  const cursor = clampCursorAbs(text, cursorAbs);
127
- const bounds = findCurrentLineBounds(text, cursor);
128
- if (bounds.startAbs >= bounds.endAbs) return null;
138
+ const bounds = findLogicalLineBounds(text, cursor);
139
+ if (bounds.start >= bounds.end) return null;
129
140
 
130
141
  let openIndex: number | null = null;
131
142
  let bestPair: { open: number; close: number } | null = null;
132
143
 
133
- for (let index = bounds.startAbs; index < bounds.endAbs; index++) {
144
+ for (let index = bounds.start; index < bounds.end; index++) {
134
145
  if (text[index] !== quote || isEscapedDelimiter(text, index)) continue;
135
146
 
136
147
  if (openIndex === null) {
@@ -138,10 +149,12 @@ export function resolveQuoteObjectRange(
138
149
  continue;
139
150
  }
140
151
 
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 };
152
+ if (openIndex <= cursor && cursor <= index) {
153
+ if (
154
+ bestPair === null ||
155
+ index - openIndex < bestPair.close - bestPair.open
156
+ ) {
157
+ bestPair = { open: openIndex, close: index };
145
158
  }
146
159
  }
147
160
  openIndex = null;
@@ -172,28 +185,19 @@ export function resolveBracketObjectRange(
172
185
  if (open.length !== 1 || close.length !== 1 || open === close) return null;
173
186
 
174
187
  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
- }
188
+ let bestPair = null as { open: number; close: number } | null;
185
189
 
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 };
190
+ scanSameDelimiterPairs(text, open, close, (openIndex, closeIndex) => {
191
+ if (openIndex <= cursor && cursor <= closeIndex) {
192
+ if (
193
+ bestPair === null ||
194
+ closeIndex - openIndex < bestPair.close - bestPair.open
195
+ ) {
196
+ bestPair = { open: openIndex, close: closeIndex };
194
197
  }
195
198
  }
196
- }
199
+ return null;
200
+ });
197
201
 
198
202
  if (bestPair === null) return null;
199
203
 
@@ -210,6 +214,44 @@ export function resolveBracketObjectRange(
210
214
  };
211
215
  }
212
216
 
217
+ export function resolveMatchingPairMotionTarget(
218
+ text: string,
219
+ cursorAbs: number,
220
+ currentLineStartAbs: number,
221
+ currentLineEndAbs: number,
222
+ ): MatchingPairMotionTarget | null {
223
+ const start = currentLineStartAbs,
224
+ end = currentLineEndAbs;
225
+ if (!text.length || start >= end) return null;
226
+ const visibleEol = cursorAbs >= end;
227
+ let sourceAbs = visibleEol ? end - 1 : Math.max(cursorAbs, start);
228
+ const rangeAnchorAbs = visibleEol ? sourceAbs : cursorAbs;
229
+ let pair = pairKind(text[sourceAbs]);
230
+ for (
231
+ let index = sourceAbs + 1;
232
+ !visibleEol && !pair && index < end;
233
+ index++
234
+ ) {
235
+ pair = pairKind(text[index]);
236
+ if (pair) sourceAbs = index;
237
+ }
238
+ if (!pair) return null;
239
+ const targetAbs = scanSameDelimiterPairs(
240
+ text,
241
+ pair[0],
242
+ pair[1],
243
+ (openAbs, closeAbs) =>
244
+ openAbs === sourceAbs
245
+ ? closeAbs
246
+ : closeAbs === sourceAbs
247
+ ? openAbs
248
+ : null,
249
+ );
250
+ if (targetAbs !== null) return { pair, sourceAbs, targetAbs, rangeAnchorAbs };
251
+
252
+ return null;
253
+ }
254
+
213
255
  export function resolveDelimitedTextObjectRange(
214
256
  text: string,
215
257
  cursorAbs: number,
@@ -224,7 +266,13 @@ export function resolveDelimitedTextObjectRange(
224
266
  }
225
267
 
226
268
  if (spec.type === "bracket") {
227
- return resolveBracketObjectRange(text, cursorAbs, kind, spec.open, spec.close);
269
+ return resolveBracketObjectRange(
270
+ text,
271
+ cursorAbs,
272
+ kind,
273
+ spec.open,
274
+ spec.close,
275
+ );
228
276
  }
229
277
 
230
278
  return null;
@@ -244,11 +292,10 @@ export function resolveWordTextObjectRange(
244
292
  const bounds = findLogicalLineBounds(line, cursor);
245
293
  if (bounds.start >= bounds.end) return null;
246
294
 
247
- const hasWordChar = (idx: number) => (
248
- idx >= bounds.start
249
- && idx < bounds.end
250
- && isWordTextObjectChar(line[idx], semanticClass)
251
- );
295
+ const hasWordChar = (idx: number) =>
296
+ idx >= bounds.start &&
297
+ idx < bounds.end &&
298
+ isWordTextObjectChar(line[idx], semanticClass);
252
299
 
253
300
  let col = Math.max(bounds.start, Math.min(cursor, bounds.end - 1));
254
301
 
@@ -275,7 +322,8 @@ export function resolveWordTextObjectRange(
275
322
  let remaining = normalizeCount(count) - 1;
276
323
  while (remaining > 0) {
277
324
  let nextWordStart = end;
278
- while (nextWordStart < bounds.end && !hasWordChar(nextWordStart)) nextWordStart++;
325
+ while (nextWordStart < bounds.end && !hasWordChar(nextWordStart))
326
+ nextWordStart++;
279
327
  if (nextWordStart >= bounds.end) break;
280
328
 
281
329
  let nextWordEnd = nextWordStart + 1;
@@ -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 = Number.isInteger(maxEntries) && maxEntries > 0
157
- ? maxEntries
158
- : DEFAULT_MAX_CACHE_ENTRIES;
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 {