pi-vim 0.10.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/README.md CHANGED
@@ -12,27 +12,35 @@ Restart Pi after install.
12
12
 
13
13
  ## configure
14
14
 
15
- pi-vim reads persistent Pi settings from `~/.pi/agent/settings.json` and project `.pi/settings.json`.
15
+ Settings are read from `~/.pi/agent/settings.json` and project `.pi/settings.json`.
16
16
 
17
- Clipboard write mirroring is controlled by `piVim.clipboardMirror`:
17
+ Default-equivalent `settings.json`:
18
18
 
19
19
  ```json
20
20
  {
21
21
  "piVim": {
22
- "clipboardMirror": "all"
22
+ "clipboardMirror": "all",
23
+ "modeColors": {
24
+ "insert": "borderMuted",
25
+ "normal": "borderAccent",
26
+ "ex": "warning"
27
+ },
28
+ "syncBorderColorWithMode": false
23
29
  }
24
30
  }
25
31
  ```
26
32
 
27
- | value | behavior |
28
- |-------|----------|
29
- | `all` | Mirror every unnamed-register write (default/current behavior) |
30
- | `yank` | Mirror yanks only; deletes/changes update only pi-vim's internal register |
31
- | `never` | Never mirror register writes to the OS clipboard |
33
+ All keys are optional; omitting `piVim` is equivalent. Project overrides global; project `modeColors` replaces global `modeColors`, with missing modes defaulting above.
32
34
 
33
- The setting controls which local register writes cross the OS clipboard boundary. `p` / `P` keep non-mirrored writes local.
35
+ `clipboardMirror`: `all` mirrors unnamed writes; `yank` mirrors yanks; `never` keeps writes internal. Non-mirrored writes stay local for `p` / `P`.
34
36
 
35
- Mode colors: `piVim.modeColors` accepts pi theme tokens (`insert`: `borderMuted`, `normal`: `borderAccent`, `ex`: `warning`); missing keys and unknown tokens use defaults. `piVim.syncBorderColorWithMode` defaults `false`; `true` syncs border to mode, overriding Pi's normal thinking-level border signal.
37
+ `syncBorderColorWithMode`: `false` keeps Pi thinking border; `true` follows mode colors.
38
+
39
+ ### mode colors
40
+
41
+ `piVim.modeColors` accepts Pi theme foreground tokens. Missing, invalid, or unknown tokens use defaults above.
42
+
43
+ Usual/safest: `accent`, `border`, `borderAccent`, `borderMuted`, `success`, `error`, `warning`, `muted`, `dim`, `text`, `thinkingText`.
36
44
 
37
45
  ## wrapping pi-vim
38
46
 
@@ -57,7 +65,7 @@ npm run hooks:install
57
65
 
58
66
  ## stats
59
67
 
60
- - **188 commands**: motions, operators, counts, text objects, undo/redo, ex quit
68
+ - **192 commands**: motions, operators, counts, text objects, undo/redo, ex quit
61
69
  - **sub-µs word motions** via precomputed boundary cache (~4ms startup, ~150KB memory)
62
70
  - **0 dependencies**
63
71
 
@@ -85,13 +93,12 @@ Requires `@mariozechner/pi-tui >= 0.47.0`. With `pi-tui >= 0.49.3` and DECSCUSR
85
93
  - REPL-focused defaults; out-of-scope boundaries documented.
86
94
  - Clipboard/register behavior is explicit and tested.
87
95
 
88
- Use pi-vim for fast Vim muscle-memory in Pi prompts. Skip it if you need
89
- full Vim parity (visual mode, macros, search, extended ex-commands, …).
96
+ Use pi-vim for Vim muscle-memory in Pi prompts. Skip it if you need full Vim parity (visual mode, macros, search, extended ex-commands, …).
90
97
 
91
98
  ## common recipes
92
99
 
93
100
  | goal | keys |
94
- |------|------|
101
+ |---|---|
95
102
  | Jump to exact line 25 | `25gg` (or `25G`) |
96
103
  | Delete two words | `2dw` |
97
104
  | Change current whitespace-delimited WORD | `ciW` |
@@ -113,19 +120,19 @@ full Vim parity (visual mode, macros, search, extended ex-commands, …).
113
120
 
114
121
  ### mode switching
115
122
 
116
- | key | action |
117
- |----------|----------------------------------------|
118
- | `Esc` / `Ctrl+[` | Insert → Normal mode |
123
+ | key | action |
124
+ |---|---|
125
+ | `Esc` / `Ctrl+[` | Insert → Normal mode |
119
126
  | `Esc` / `Ctrl+[` | Normal mode → pass to Pi (aborts the agent under default Pi keybindings) |
120
- | `:` | Normal → EX mini-mode |
121
- | `i` | Normal → Insert at cursor |
122
- | `a` | Normal → Insert after cursor |
123
- | `I` | Normal → Insert at first non-whitespace |
124
- | `A` | Normal → Insert at line end |
125
- | `o` | Normal → open line below + Insert |
126
- | `O` | Normal → open line above + Insert |
127
+ | `:` | Normal → EX mini-mode |
128
+ | `i` | Normal → Insert at cursor |
129
+ | `a` | Normal → Insert after cursor |
130
+ | `I` | Normal → Insert at first non-whitespace |
131
+ | `A` | Normal → Insert at line end |
132
+ | `o` | Normal → open line below + Insert |
133
+ | `O` | Normal → open line above + Insert |
127
134
 
128
- Optional: heavy users may want to move Pi's `app.interrupt` off bare `escape` in `~/.pi/agent/keybindings.json` since it overlaps with Insert→Normal. Pick your own replacement; user config overrides defaults.
135
+ Optional: move Pi's `app.interrupt` off bare `escape` in `~/.pi/agent/keybindings.json` if it overlaps with Insert→Normal; user config wins.
129
136
 
130
137
  #### ex mini-mode
131
138
 
@@ -145,45 +152,48 @@ Quit-only ex flows.
145
152
 
146
153
  Insert-mode shortcuts (stay in Insert mode):
147
154
 
148
- | key | action |
149
- |-----------------|------------------------|
150
- | `Shift+Alt+A` | Go to end of line |
151
- | `Shift+Alt+I` | Go to start of line |
152
- | `Alt+o` | Open line below |
153
- | `Alt+Shift+O` | Open line above |
155
+ | key | action |
156
+ |---|---|
157
+ | `Shift+Alt+A` | Go to end of line |
158
+ | `Shift+Alt+I` | Go to start of line |
159
+ | `Alt+o` | Open line below |
160
+ | `Alt+Shift+O` | Open line above |
154
161
 
155
162
  ---
156
163
 
157
164
  ### navigation (normal mode)
158
165
 
159
- A `{count}` prefix can be prepended to navigation keys (max: `9999`).
166
+ Most navigation keys accept a `{count}` prefix (max: `9999`); `%` intentionally does not.
160
167
 
161
168
  | key | action |
162
- |-----|--------|
169
+ |---|---|
163
170
  | `h` / `l` / `j` / `k`; `{count}h/l/j/k` | Move left/right/down/up; line moves clamp to the buffer |
164
171
  | `0` / `^` / `_` / `$` | Line start / first non-whitespace / counted first non-whitespace / line end |
165
172
  | `gg` / `G`; `{count}gg` / `{count}G` | Buffer start/end or absolute 1-indexed line |
166
173
  | `w` / `b` / `e`; `{count}w/b/e` | `word` start/back/end motions |
167
174
  | `W` / `B` / `E`; `{count}W/B/E` | whitespace-delimited `WORD` motions |
168
175
  | `{` / `}`; `{count}{` / `{count}}` | Previous/next paragraph start |
176
+ | `%` | Jump to the matching `()`, `[]`, or `{}` partner |
169
177
 
170
178
  `word` splits punctuation from keyword chars; `WORD` treats any non-whitespace run as one token (`foo-bar`, `path/to`). Paragraph starts are non-blank lines at BOF or after blank lines (`^\s*$`). `{` / `}` are navigation-only; brace operator forms (`d{`, `c}`, `y{`, …) are out of scope.
171
179
 
180
+ `%` uses a delimiter under the cursor or scans forward on the current logical line. It matches `()`, `[]`, `{}` buffer-wide with lexical, nested, same-delimiter, parser-unaware matching; quotes/comments and mixed delimiters are not special. Missing/unmatched sources no-op. Counts are unsupported: `{count}%` consumes the count and no-ops; counted `d%` / `y%` / `c%` cancel without writes.
181
+
172
182
  ---
173
183
 
174
184
  ### character-find motions (normal mode)
175
185
 
176
186
  A `{count}` prefix finds the Nth occurrence of `{char}` on the line.
177
187
 
178
- | key | action |
179
- |------------------|------------------------------------------------|
180
- | `f{char}` | Jump forward to `char` (inclusive) |
181
- | `F{char}` | Jump backward to `char` (inclusive) |
182
- | `t{char}` | Jump forward to one before `char` (exclusive) |
183
- | `T{char}` | Jump backward to one after `char` (exclusive) |
184
- | `{count}f{char}` | Jump to Nth occurrence of `char` forward |
185
- | `;` | Repeat last `f/F/t/T` motion |
186
- | `,` | Repeat last motion in reverse direction |
188
+ | key | action |
189
+ |---|---|
190
+ | `f{char}` | Jump forward to `char` (inclusive) |
191
+ | `F{char}` | Jump backward to `char` (inclusive) |
192
+ | `t{char}` | Jump forward to one before `char` (exclusive) |
193
+ | `T{char}` | Jump backward to one after `char` (exclusive) |
194
+ | `{count}f{char}` | Jump to Nth occurrence of `char` forward |
195
+ | `;` | Repeat last `f/F/t/T` motion |
196
+ | `,` | Repeat last motion in reverse direction |
187
197
 
188
198
  Char-find motions compose with operators: `df{char}`, `ct{char}`, `d{count}t{char}`, etc.
189
199
 
@@ -198,7 +208,7 @@ Register-writing edits write to the unnamed register. With the default clipboard
198
208
  Text objects compose as `d`/`c`/`y` + `i`/`a` + object. `i` means inner; `a` means around.
199
209
 
200
210
  | object | keys | range |
201
- |--------|------|-------|
211
+ |---|---|---|
202
212
  | word | `iw` / `aw` | Keyword word; `aw` includes spaces |
203
213
  | WORD | `iW` / `aW` | Line-local whitespace-delimited WORD; `aW` includes adjacent whitespace |
204
214
  | quotes | `i"` / `a"`, `i'` / `a'`, <code>i`</code> / <code>a`</code> | Smallest containing quote pair on the line |
@@ -219,12 +229,13 @@ A `{count}` or dual-count prefix (`{pfx}d{op}{motion}`) is supported for word,
219
229
  WORD, char-find, and linewise motions. Maximum total count: `9999`.
220
230
 
221
231
  | command | deletes |
222
- |---------|---------|
232
+ |---|---|
223
233
  | `dw` / `de` / `db`; `dW` / `dE` / `dB` | word/WORD motion ranges; `{count}` repeats |
224
234
  | `d$` / `d0` / `d^` | To EOL / BOL / first non-whitespace |
225
235
  | `d_` / `dd`; `d{count}_` / `{count}dd` | Current or counted whole lines |
226
236
  | `d{count}j` / `d{count}k` / `dG` | Linewise down/up/to EOF |
227
237
  | `df{c}` / `dt{c}` / `dF{c}` / `dT{c}`; `d{count}f{c}` | Char-find ranges |
238
+ | `d%` | Inclusive range through the matching pair target |
228
239
  | `diw` / `daw`; `diW` / `daW` | Inner/around word or WORD |
229
240
  | `d{count}iw` / `d{count}iW`; `d{count}aw` / `d{count}aW` | Counted word/WORD text objects |
230
241
  | `di"` / `da"` (`'`, <code>`</code>) | Inside/around quotes |
@@ -235,7 +246,7 @@ WORD, char-find, and linewise motions. Maximum total count: `9999`.
235
246
  Same motion and count set as `d`. Deletes text then enters Insert mode.
236
247
 
237
248
  | command | action |
238
- |---------|--------|
249
+ |---|---|
239
250
  | `cw` / `ce` / `cb`; `cW` / `cE` / `cB` | Change word/WORD motion ranges + Insert |
240
251
  | `c{count}w/e/b`; `c{count}W/E/B` | Change counted word/WORD motions + Insert |
241
252
  | `ciw` / `caw`; `ciW` / `caW` | Change word/WORD text objects + Insert |
@@ -244,22 +255,23 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
244
255
  | `ci(` / `ca(`, `ci[` / `ca[`, `ci{` / `ca{` | Change inside/around brackets + Insert |
245
256
  | `cc` / `c_`; `c{count}_` | Change current or counted whole lines + Insert |
246
257
  | `c$` / `c0` / `c^` | Delete to EOL / BOL / first non-whitespace + Insert |
258
+ | `c%` | Change inclusive range through the matching pair target + Insert |
247
259
  | … | All `d` motions apply |
248
260
 
249
261
  #### single-key edits
250
262
 
251
263
  A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
252
264
 
253
- | key | action |
254
- |--------------|---------------------------------------------------------------|
255
- | `x` | Delete char under cursor (no-op at/past EOL) |
256
- | `{count}x` | Delete `{count}` chars |
257
- | `s` | Delete char under cursor + Insert mode |
258
- | `S` | Delete line content + Insert mode |
259
- | `D` | Delete cursor to EOL (captures `\n` if at EOL with next line) |
260
- | `C` | Delete cursor to EOL + Insert mode |
261
- | `r{char}` | Replace char under cursor with `{char}` (stays in Normal) |
262
- | `{count}r{char}` | Replace next `{count}` chars with `{char}` |
265
+ | key | action |
266
+ |---|---|
267
+ | `x` | Delete char under cursor (no-op at/past EOL) |
268
+ | `{count}x` | Delete `{count}` chars |
269
+ | `s` | Delete char under cursor + Insert mode |
270
+ | `S` | Delete line content + Insert mode |
271
+ | `D` | Delete cursor to EOL (captures `\n` if at EOL with next line) |
272
+ | `C` | Delete cursor to EOL + Insert mode |
273
+ | `r{char}` | Replace char under cursor with `{char}` (stays in Normal) |
274
+ | `{count}r{char}` | Replace next `{count}` chars with `{char}` |
263
275
 
264
276
  ---
265
277
 
@@ -268,11 +280,12 @@ A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
268
280
  Same motion set as `d`. Writes to register, **no text mutation**.
269
281
 
270
282
  | command | yanks |
271
- |---------|-------|
283
+ |---|---|
272
284
  | `yy` / `Y`; `{count}yy` / `{count}Y` | Whole line(s) + trailing `\n` |
273
285
  | `y{count}j` / `y{count}k` / `yG`; `y_` / `y{count}_` | Linewise ranges |
274
286
  | `yw` / `ye` / `yb`; `yW` / `yE` / `yB` | word/WORD motion ranges |
275
287
  | `y$` / `y0` / `y^`; `yf{c}` | EOL / BOL / first non-whitespace / char-find |
288
+ | `y%` | Inclusive range through the matching pair target |
276
289
  | `yiw` / `yaw`; `yiW` / `yaW` | Inner/around word or WORD |
277
290
  | `yi"` / `ya"` (`'`, <code>`</code>) | Inside/around quotes |
278
291
  | `yi(` / `ya(`, `yi[` / `ya[`, `yi{` / `ya{` | Inside/around brackets; aliases `)`, `]`, `}`, `b`, `B` |
@@ -286,12 +299,12 @@ implemented and cancel the pending operator. Linewise counted yank (`{count}yy`,
286
299
 
287
300
  ### put / paste
288
301
 
289
- | key | action |
290
- |--------------|-------------------------------------------------------------|
291
- | `p` | Put after cursor (char-wise) / new line below (line-wise) |
292
- | `P` | Put before cursor (char-wise) / new line above (line-wise) |
293
- | `{count}p` | Put `{count}` times after cursor |
294
- | `{count}P` | Put `{count}` times before cursor |
302
+ | key | action |
303
+ |---|---|
304
+ | `p` | Put after cursor (char-wise) / new line below (line-wise) |
305
+ | `P` | Put before cursor (char-wise) / new line above (line-wise) |
306
+ | `{count}p` | Put `{count}` times after cursor |
307
+ | `{count}P` | Put `{count}` times before cursor |
295
308
 
296
309
  Put reads the OS clipboard first unless the last local register write was not mirrored. Paste text ending in `\n` is line-wise.
297
310
 
@@ -325,13 +338,14 @@ Put reads the OS clipboard first unless the last local register write was not mi
325
338
  ## known differences from full Vim
326
339
 
327
340
  | area | this extension | full Vim |
328
- |------|----------------|----------|
341
+ |---|---|---|
329
342
  | `$` motion | Moves past the last char (readline `Ctrl+E`) | Moves to the last char |
330
343
  | `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for both `word` and `WORD` motions | Cross-line |
331
344
  | `0` / `$` operators | Exclusive of the anchor col | `0` is inclusive of col 0 |
332
345
  | Undo / redo | Delegates undo to readline; normal-mode `<C-r>` redo is supported | Full per-change undo tree |
333
346
  | Visual mode | Not implemented | `v`, `V`, `<C-v>` |
334
347
  | Text objects | `iw` / `aw`, `iW` / `aW`, quote objects, and paren/bracket/brace objects; delimited counts cancel | Full text-object set |
348
+ | `%` matching | `()`, `[]`, `{}` only; lexical same-delimiter matching with no counts, quote/angle matching, parser/matchit logic, mixed-delimiter validation, or Visual `%` yet | Also supports percentage jumps and broader matching |
335
349
  | Count prefix | Operators, motions, navigation, `x`, `r`, `p`, `P`; capped at `MAX_COUNT=9999` | Full support |
336
350
  | Registers / macros / search | Not implemented | Supported |
337
351
  | Ex commands | Quit-only EX mini-mode (`:q`, `:q!`, `:qa`, `:qa!`) | Full ex command-line surface |
@@ -343,18 +357,18 @@ Put reads the OS clipboard first unless the last local register write was not mi
343
357
 
344
358
  Explicitly deferred:
345
359
 
346
- - Visual modes (`v`, `V`, block visual)
360
+ - Visual modes (`v`, `V`, block visual), including Visual `%`
347
361
  - Tag text objects (`it`, `at`)
348
362
  - Paragraph/sentence text objects (`ip`, `ap`, `is`, `as`)
349
- - Angle bracket text objects (`i<`, `a<`)
363
+ - Angle bracket text objects (`i<`, `a<`) or angle-bracket `%` matching
350
364
  - Visual-mode text-object selection
351
- - Parser-aware delimiter matching
365
+ - Quote matching via `%`, parser-aware delimiter matching, matchit-style matching, and mixed-delimiter structural validation
352
366
  - Delimited-object counts (`d2i"`, `2ci(`, `y2a{`)
353
367
  - Named registers (`"a`, `"b`, …), macros (`q{char}`, `@{char}`)
354
368
  - Ex surface beyond quit (`:s`, `:g`, `:w`, `:r`, …)
355
369
  - Search (`/`, `?`, `n`, `N`), repeat (`.`)
356
370
  - Replace mode (`R`) — only `r{char}` is supported
357
- - Count prefix beyond currently supported motions
371
+ - Count prefix beyond currently supported motions, including `{count}%` percent-of-file jumps
358
372
  - No insert-mode `<C-r>` expansion, no cross-session redo persistence
359
373
  - No upstream `pi-tui` redo prerequisite
360
374
  - Window / tab / buffer management, plugin ecosystem compatibility
@@ -363,15 +377,6 @@ Explicitly deferred:
363
377
 
364
378
  ## architecture notes
365
379
 
366
- - `index.ts` `ModalEditor` subclass of `CustomEditor`; all key handling.
367
- - `motions.ts` — pure motion calculation helpers (`findWordMotionTarget`,
368
- `findCharMotionTarget`); no side effects.
369
- - `types.ts` — shared types and escape-sequence constants.
370
- - `test/` — Node test runner suite; no browser / full runtime required.
380
+ - `index.ts` handles modal keys; `motions.ts` and `text-objects.ts` hold pure range logic; `types.ts` holds shared types/constants; `test/` uses Node's runner.
371
381
 
372
- Run checks:
373
-
374
- ```
375
- cd pi-vim
376
- npm run check
377
- ```
382
+ Run checks with `npm run check`.
package/index.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  import { type ModeColorSettings, readPiVimSettings } from "./settings.js";
26
26
  import {
27
27
  resolveDelimitedTextObjectRange,
28
+ resolveMatchingPairMotionTarget,
28
29
  resolveWordTextObjectRange,
29
30
  type TextObjectKind,
30
31
  type TextObjectRange,
@@ -1408,6 +1409,13 @@ export class ModalEditor extends CustomEditor {
1408
1409
  return this.prefixCount.length > 0 || this.operatorCount.length > 0;
1409
1410
  }
1410
1411
 
1412
+ private opDigit(data: string): boolean {
1413
+ if (!this.isDigit(data) || (data === "0" && !this.operatorCount))
1414
+ return false;
1415
+ this.operatorCount += data;
1416
+ return true;
1417
+ }
1418
+
1411
1419
  private cancelPendingOperator(data: string): void {
1412
1420
  this.pendingOperator = null;
1413
1421
  this.prefixCount = "";
@@ -1526,16 +1534,11 @@ export class ModalEditor extends CustomEditor {
1526
1534
  }
1527
1535
 
1528
1536
  private handlePendingDelete(data: string): void {
1529
- if (this.isDigit(data)) {
1530
- if (this.operatorCount.length === 0) {
1531
- if (data !== "0") {
1532
- this.operatorCount = data;
1533
- return;
1534
- }
1535
- } else {
1536
- this.operatorCount += data;
1537
- return;
1538
- }
1537
+ if (this.opDigit(data)) return;
1538
+
1539
+ if (data === "%") {
1540
+ this.applyPercentOp();
1541
+ return;
1539
1542
  }
1540
1543
 
1541
1544
  if (data === "d") {
@@ -1578,8 +1581,7 @@ export class ModalEditor extends CustomEditor {
1578
1581
  return;
1579
1582
  }
1580
1583
 
1581
- const hasCount =
1582
- this.prefixCount.length > 0 || this.operatorCount.length > 0;
1584
+ const hasCount = this.hasPendingCount();
1583
1585
  const supportsCountedWordMotion =
1584
1586
  data === "w" ||
1585
1587
  data === "e" ||
@@ -1609,16 +1611,11 @@ export class ModalEditor extends CustomEditor {
1609
1611
  }
1610
1612
 
1611
1613
  private handlePendingChange(data: string): void {
1612
- if (this.isDigit(data)) {
1613
- if (this.operatorCount.length === 0) {
1614
- if (data !== "0") {
1615
- this.operatorCount = data;
1616
- return;
1617
- }
1618
- } else {
1619
- this.operatorCount += data;
1620
- return;
1621
- }
1614
+ if (this.opDigit(data)) return;
1615
+
1616
+ if (data === "%") {
1617
+ this.applyPercentOp();
1618
+ return;
1622
1619
  }
1623
1620
 
1624
1621
  if (data === "c") {
@@ -1659,8 +1656,7 @@ export class ModalEditor extends CustomEditor {
1659
1656
  return;
1660
1657
  }
1661
1658
 
1662
- const hasCount =
1663
- this.prefixCount.length > 0 || this.operatorCount.length > 0;
1659
+ const hasCount = this.hasPendingCount();
1664
1660
  const supportsCountedWordMotion =
1665
1661
  data === "w" ||
1666
1662
  data === "e" ||
@@ -1726,6 +1722,12 @@ export class ModalEditor extends CustomEditor {
1726
1722
  return;
1727
1723
  }
1728
1724
 
1725
+ if (data === "%") {
1726
+ this.prefixCount = "";
1727
+ this.operatorCount = "";
1728
+ return;
1729
+ }
1730
+
1729
1731
  if (data === "d" || data === "y") {
1730
1732
  this.pendingOperator = data;
1731
1733
  return;
@@ -1941,6 +1943,10 @@ export class ModalEditor extends CustomEditor {
1941
1943
  this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
1942
1944
  return;
1943
1945
  }
1946
+ if (data === "%") {
1947
+ this.moveToMatchingPairTarget();
1948
+ return;
1949
+ }
1944
1950
  if (data === "B") {
1945
1951
  this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
1946
1952
  return;
@@ -2303,6 +2309,40 @@ export class ModalEditor extends CustomEditor {
2303
2309
  return this.getAbsoluteIndex(cursor.line, cursor.col);
2304
2310
  }
2305
2311
 
2312
+ private getMatchingPairMotionTarget() {
2313
+ const cursor = this.getCursor();
2314
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
2315
+ return resolveMatchingPairMotionTarget(
2316
+ this.getText(),
2317
+ this.getAbsoluteIndexFromCursor(),
2318
+ lineStartAbs,
2319
+ lineStartAbs + (this.getLines()[cursor.line] ?? "").length,
2320
+ );
2321
+ }
2322
+
2323
+ private moveToMatchingPairTarget(): void {
2324
+ const target = this.getMatchingPairMotionTarget();
2325
+ if (target) this.moveCursorToAbsoluteIndex(target.targetAbs);
2326
+ }
2327
+
2328
+ private applyPercentOp(): void {
2329
+ const op = this.pendingOperator;
2330
+ const counted = this.hasPendingCount();
2331
+ this.clearPendingState();
2332
+ if (!op || counted) return;
2333
+
2334
+ const t = this.getMatchingPairMotionTarget();
2335
+ if (!t) return;
2336
+
2337
+ if (op === "y") {
2338
+ this.yankRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
2339
+ return;
2340
+ }
2341
+
2342
+ this.deleteRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
2343
+ if (op === "c") this.mode = "insert";
2344
+ }
2345
+
2306
2346
  private getDelimitedTextObjectCursorAbs(): number {
2307
2347
  const lines = this.getLines();
2308
2348
  const cursor = this.getCursor();
@@ -2827,20 +2867,15 @@ export class ModalEditor extends CustomEditor {
2827
2867
  if (targetCol === null) return;
2828
2868
 
2829
2869
  this.lastCharMotion = { motion, char: targetChar };
2830
- this.deleteRange(col, targetCol, true); // char motions are inclusive
2870
+ this.deleteRange(col, targetCol, true);
2831
2871
  }
2832
2872
 
2833
2873
  private handlePendingYank(data: string): void {
2834
- if (this.isDigit(data)) {
2835
- if (this.operatorCount.length === 0) {
2836
- if (data !== "0") {
2837
- this.operatorCount = data;
2838
- return;
2839
- }
2840
- } else {
2841
- this.operatorCount += data;
2842
- return;
2843
- }
2874
+ if (this.opDigit(data)) return;
2875
+
2876
+ if (data === "%") {
2877
+ this.applyPercentOp();
2878
+ return;
2844
2879
  }
2845
2880
 
2846
2881
  if (data === "y") {
@@ -2973,7 +3008,7 @@ export class ModalEditor extends CustomEditor {
2973
3008
  if (targetCol === null) return;
2974
3009
 
2975
3010
  this.lastCharMotion = { motion, char: targetChar };
2976
- this.yankRange(col, targetCol, true); // char motions are inclusive
3011
+ this.yankRange(col, targetCol, true);
2977
3012
  }
2978
3013
 
2979
3014
  private yankRange(col: number, targetCol: number, inclusive: boolean): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vim",
3
- "version": "0.10.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": [
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);
@@ -45,23 +54,7 @@ function findLogicalLineBounds(
45
54
  const start = line.lastIndexOf("\n", previousSearchStart) + 1;
46
55
  const nextNewline = line.indexOf("\n", cursorCol);
47
56
 
48
- return {
49
- start,
50
- end: nextNewline === -1 ? line.length : nextNewline,
51
- };
52
- }
53
-
54
- function findCurrentLineBounds(
55
- text: string,
56
- cursorAbs: number,
57
- ): { startAbs: number; endAbs: number } {
58
- const cursor = clampCursorAbs(text, cursorAbs);
59
- const bounds = findLogicalLineBounds(text, cursor);
60
-
61
- return {
62
- startAbs: bounds.start,
63
- endAbs: bounds.end,
64
- };
57
+ return { start, end: nextNewline === -1 ? line.length : nextNewline };
65
58
  }
66
59
 
67
60
  function isWordTextObjectChar(
@@ -77,42 +70,49 @@ function isWhitespace(ch: string | undefined): boolean {
77
70
  return ch !== undefined && /\s/.test(ch);
78
71
  }
79
72
 
80
- export function normalizeDelimiterKey(key: string): DelimiterSpec | null {
81
- if (key === '"' || key === "'" || key === "`") {
82
- return {
83
- type: "quote",
84
- open: key,
85
- close: key,
86
- };
87
- }
88
-
89
- if (key === "(" || key === ")" || key === "b") {
90
- return {
91
- type: "bracket",
92
- open: "(",
93
- close: ")",
94
- };
95
- }
96
-
97
- if (key === "[" || key === "]") {
98
- return {
99
- type: "bracket",
100
- open: "[",
101
- close: "]",
102
- };
103
- }
73
+ function pairKind(ch?: string): MatchingPairKind | null {
74
+ return ch === "(" || ch === ")"
75
+ ? "()"
76
+ : ch === "[" || ch === "]"
77
+ ? "[]"
78
+ : ch === "{" || ch === "}"
79
+ ? "{}"
80
+ : null;
81
+ }
104
82
 
105
- if (key === "{" || key === "}" || key === "B") {
106
- return {
107
- type: "bracket",
108
- open: "{",
109
- close: "}",
110
- };
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
+ }
111
98
  }
112
-
113
99
  return null;
114
100
  }
115
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
+
116
116
  export function isEscapedDelimiter(text: string, index: number): boolean {
117
117
  if (!Number.isInteger(index) || index <= 0 || index >= text.length)
118
118
  return false;
@@ -135,13 +135,13 @@ export function resolveQuoteObjectRange(
135
135
  if (spec?.type !== "quote") return null;
136
136
 
137
137
  const cursor = clampCursorAbs(text, cursorAbs);
138
- const bounds = findCurrentLineBounds(text, cursor);
139
- if (bounds.startAbs >= bounds.endAbs) return null;
138
+ const bounds = findLogicalLineBounds(text, cursor);
139
+ if (bounds.start >= bounds.end) return null;
140
140
 
141
141
  let openIndex: number | null = null;
142
142
  let bestPair: { open: number; close: number } | null = null;
143
143
 
144
- for (let index = bounds.startAbs; index < bounds.endAbs; index++) {
144
+ for (let index = bounds.start; index < bounds.end; index++) {
145
145
  if (text[index] !== quote || isEscapedDelimiter(text, index)) continue;
146
146
 
147
147
  if (openIndex === null) {
@@ -149,13 +149,12 @@ export function resolveQuoteObjectRange(
149
149
  continue;
150
150
  }
151
151
 
152
- const closeIndex = index;
153
- if (openIndex <= cursor && cursor <= closeIndex) {
152
+ if (openIndex <= cursor && cursor <= index) {
154
153
  if (
155
154
  bestPair === null ||
156
- closeIndex - openIndex < bestPair.close - bestPair.open
155
+ index - openIndex < bestPair.close - bestPair.open
157
156
  ) {
158
- bestPair = { open: openIndex, close: closeIndex };
157
+ bestPair = { open: openIndex, close: index };
159
158
  }
160
159
  }
161
160
  openIndex = null;
@@ -186,31 +185,19 @@ export function resolveBracketObjectRange(
186
185
  if (open.length !== 1 || close.length !== 1 || open === close) return null;
187
186
 
188
187
  const cursor = clampCursorAbs(text, cursorAbs);
189
- const openStack: number[] = [];
190
- let bestPair: { open: number; close: number } | null = null;
191
-
192
- for (let index = 0; index < text.length; index++) {
193
- const ch = text[index];
194
-
195
- if (ch === open) {
196
- openStack.push(index);
197
- continue;
198
- }
199
-
200
- if (ch !== close) continue;
201
-
202
- const openIndex = openStack.pop();
203
- if (openIndex === undefined) continue;
188
+ let bestPair = null as { open: number; close: number } | null;
204
189
 
205
- if (openIndex <= cursor && cursor <= index) {
190
+ scanSameDelimiterPairs(text, open, close, (openIndex, closeIndex) => {
191
+ if (openIndex <= cursor && cursor <= closeIndex) {
206
192
  if (
207
193
  bestPair === null ||
208
- index - openIndex < bestPair.close - bestPair.open
194
+ closeIndex - openIndex < bestPair.close - bestPair.open
209
195
  ) {
210
- bestPair = { open: openIndex, close: index };
196
+ bestPair = { open: openIndex, close: closeIndex };
211
197
  }
212
198
  }
213
- }
199
+ return null;
200
+ });
214
201
 
215
202
  if (bestPair === null) return null;
216
203
 
@@ -227,6 +214,44 @@ export function resolveBracketObjectRange(
227
214
  };
228
215
  }
229
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
+
230
255
  export function resolveDelimitedTextObjectRange(
231
256
  text: string,
232
257
  cursorAbs: number,