pi-vim 0.10.0 → 0.11.1

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,
@@ -1214,6 +1215,7 @@ export class ModalEditor extends CustomEditor {
1214
1215
  if ("insert" === this.mode) {
1215
1216
  this.clearUnderlyingPasteStateIfActive();
1216
1217
  this.setMode("normal");
1218
+ if (this.getCursor().col > 0) this.moveCursorBy(-1);
1217
1219
  } else {
1218
1220
  super.handleInput("\x1b"); // pass escape to abort agent
1219
1221
  }
@@ -1408,6 +1410,13 @@ export class ModalEditor extends CustomEditor {
1408
1410
  return this.prefixCount.length > 0 || this.operatorCount.length > 0;
1409
1411
  }
1410
1412
 
1413
+ private opDigit(data: string): boolean {
1414
+ if (!this.isDigit(data) || (data === "0" && !this.operatorCount))
1415
+ return false;
1416
+ this.operatorCount += data;
1417
+ return true;
1418
+ }
1419
+
1411
1420
  private cancelPendingOperator(data: string): void {
1412
1421
  this.pendingOperator = null;
1413
1422
  this.prefixCount = "";
@@ -1526,16 +1535,11 @@ export class ModalEditor extends CustomEditor {
1526
1535
  }
1527
1536
 
1528
1537
  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
- }
1538
+ if (this.opDigit(data)) return;
1539
+
1540
+ if (data === "%") {
1541
+ this.applyPercentOp();
1542
+ return;
1539
1543
  }
1540
1544
 
1541
1545
  if (data === "d") {
@@ -1578,8 +1582,7 @@ export class ModalEditor extends CustomEditor {
1578
1582
  return;
1579
1583
  }
1580
1584
 
1581
- const hasCount =
1582
- this.prefixCount.length > 0 || this.operatorCount.length > 0;
1585
+ const hasCount = this.hasPendingCount();
1583
1586
  const supportsCountedWordMotion =
1584
1587
  data === "w" ||
1585
1588
  data === "e" ||
@@ -1609,16 +1612,11 @@ export class ModalEditor extends CustomEditor {
1609
1612
  }
1610
1613
 
1611
1614
  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
- }
1615
+ if (this.opDigit(data)) return;
1616
+
1617
+ if (data === "%") {
1618
+ this.applyPercentOp();
1619
+ return;
1622
1620
  }
1623
1621
 
1624
1622
  if (data === "c") {
@@ -1659,8 +1657,7 @@ export class ModalEditor extends CustomEditor {
1659
1657
  return;
1660
1658
  }
1661
1659
 
1662
- const hasCount =
1663
- this.prefixCount.length > 0 || this.operatorCount.length > 0;
1660
+ const hasCount = this.hasPendingCount();
1664
1661
  const supportsCountedWordMotion =
1665
1662
  data === "w" ||
1666
1663
  data === "e" ||
@@ -1726,6 +1723,12 @@ export class ModalEditor extends CustomEditor {
1726
1723
  return;
1727
1724
  }
1728
1725
 
1726
+ if (data === "%") {
1727
+ this.prefixCount = "";
1728
+ this.operatorCount = "";
1729
+ return;
1730
+ }
1731
+
1729
1732
  if (data === "d" || data === "y") {
1730
1733
  this.pendingOperator = data;
1731
1734
  return;
@@ -1941,6 +1944,10 @@ export class ModalEditor extends CustomEditor {
1941
1944
  this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
1942
1945
  return;
1943
1946
  }
1947
+ if (data === "%") {
1948
+ this.moveToMatchingPairTarget();
1949
+ return;
1950
+ }
1944
1951
  if (data === "B") {
1945
1952
  this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
1946
1953
  return;
@@ -1990,6 +1997,12 @@ export class ModalEditor extends CustomEditor {
1990
1997
  this.setMode();
1991
1998
  this.moveCursorToFirstNonWhitespace();
1992
1999
  break;
2000
+ case "$": {
2001
+ const { line } = this.getCurrentLineAndCol();
2002
+ const graphemes = getLineGraphemes(line);
2003
+ this.moveCursorToCol(graphemes[graphemes.length - 1]?.start ?? 0);
2004
+ break;
2005
+ }
1993
2006
  case "o":
1994
2007
  this.openLineBelow();
1995
2008
  this.setMode();
@@ -2017,7 +2030,7 @@ export class ModalEditor extends CustomEditor {
2017
2030
  this.setMode();
2018
2031
  break;
2019
2032
  case "x":
2020
- this.cutCharUnderCursor();
2033
+ this.cutCharUnderCursor(true);
2021
2034
  break;
2022
2035
  case "j":
2023
2036
  this.moveCursorVertically(1);
@@ -2303,6 +2316,40 @@ export class ModalEditor extends CustomEditor {
2303
2316
  return this.getAbsoluteIndex(cursor.line, cursor.col);
2304
2317
  }
2305
2318
 
2319
+ private getMatchingPairMotionTarget() {
2320
+ const cursor = this.getCursor();
2321
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
2322
+ return resolveMatchingPairMotionTarget(
2323
+ this.getText(),
2324
+ this.getAbsoluteIndexFromCursor(),
2325
+ lineStartAbs,
2326
+ lineStartAbs + (this.getLines()[cursor.line] ?? "").length,
2327
+ );
2328
+ }
2329
+
2330
+ private moveToMatchingPairTarget(): void {
2331
+ const target = this.getMatchingPairMotionTarget();
2332
+ if (target) this.moveCursorToAbsoluteIndex(target.targetAbs);
2333
+ }
2334
+
2335
+ private applyPercentOp(): void {
2336
+ const op = this.pendingOperator;
2337
+ const counted = this.hasPendingCount();
2338
+ this.clearPendingState();
2339
+ if (!op || counted) return;
2340
+
2341
+ const t = this.getMatchingPairMotionTarget();
2342
+ if (!t) return;
2343
+
2344
+ if (op === "y") {
2345
+ this.yankRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
2346
+ return;
2347
+ }
2348
+
2349
+ this.deleteRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
2350
+ if (op === "c") this.mode = "insert";
2351
+ }
2352
+
2306
2353
  private getDelimitedTextObjectCursorAbs(): number {
2307
2354
  const lines = this.getLines();
2308
2355
  const cursor = this.getCursor();
@@ -2615,7 +2662,7 @@ export class ModalEditor extends CustomEditor {
2615
2662
  return col >= line.length;
2616
2663
  }
2617
2664
 
2618
- private cutCharUnderCursor(): void {
2665
+ private cutCharUnderCursor(normal: boolean = false): void {
2619
2666
  const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
2620
2667
  const cursor = this.getCursor();
2621
2668
  const line = this.getLines()[cursor.line] ?? "";
@@ -2630,6 +2677,10 @@ export class ModalEditor extends CustomEditor {
2630
2677
  text.slice(lineStartAbs + range.end),
2631
2678
  lineStartAbs + range.start,
2632
2679
  );
2680
+ if (normal) {
2681
+ const { line, col } = this.getCurrentLineAndCol();
2682
+ if (line && col >= line.length) this.moveCursorBy(-1);
2683
+ }
2633
2684
  }
2634
2685
 
2635
2686
  private cutToEndOfLine(): void {
@@ -2827,20 +2878,15 @@ export class ModalEditor extends CustomEditor {
2827
2878
  if (targetCol === null) return;
2828
2879
 
2829
2880
  this.lastCharMotion = { motion, char: targetChar };
2830
- this.deleteRange(col, targetCol, true); // char motions are inclusive
2881
+ this.deleteRange(col, targetCol, true);
2831
2882
  }
2832
2883
 
2833
2884
  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
- }
2885
+ if (this.opDigit(data)) return;
2886
+
2887
+ if (data === "%") {
2888
+ this.applyPercentOp();
2889
+ return;
2844
2890
  }
2845
2891
 
2846
2892
  if (data === "y") {
@@ -2973,7 +3019,7 @@ export class ModalEditor extends CustomEditor {
2973
3019
  if (targetCol === null) return;
2974
3020
 
2975
3021
  this.lastCharMotion = { motion, char: targetChar };
2976
- this.yankRange(col, targetCol, true); // char motions are inclusive
3022
+ this.yankRange(col, targetCol, true);
2977
3023
  }
2978
3024
 
2979
3025
  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.1",
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,
package/types.ts CHANGED
@@ -19,7 +19,7 @@ export const NORMAL_KEYS: Record<string, string | null> = {
19
19
  k: "\x1b[A", // up
20
20
  l: "\x1b[C", // right
21
21
  "0": "\x01", // line start
22
- $: "\x05", // line end
22
+ $: null, // line end
23
23
  x: null, // delete char (custom clipboard handling)
24
24
  D: null, // delete to end of line (custom clipboard handling)
25
25
  C: null, // change to end of line (delete to end + insert mode)