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/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.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 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
@@ -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(line: string, cursorCol: number): { start: number; end: number } {
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 = line[cursorCol] === "\n" ? cursorCol - 1 : cursorCol;
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(text: string, cursorAbs: number): { startAbs: number; endAbs: number } {
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(ch: string | undefined, semanticClass: WordTextObjectClass): boolean {
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 === "\"" || key === "'" || 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) 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--) {
@@ -140,7 +151,10 @@ export function resolveQuoteObjectRange(
140
151
 
141
152
  const closeIndex = index;
142
153
  if (openIndex <= cursor && cursor <= closeIndex) {
143
- if (bestPair === null || closeIndex - openIndex < bestPair.close - bestPair.open) {
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 (bestPair === null || index - openIndex < bestPair.close - bestPair.open) {
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(text, cursorAbs, kind, spec.open, spec.close);
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
- && idx < bounds.end
250
- && isWordTextObjectChar(line[idx], semanticClass)
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)) 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;
@@ -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 {