@oh-my-pi/pi-tui 6.8.2 → 6.8.3

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
@@ -225,7 +225,9 @@ editor.borderColor = (s) => chalk.blue(s); // Change border dynamically
225
225
  - `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable)
226
226
  - `Tab` - Autocomplete
227
227
  - `Ctrl+K` - Delete line
228
+ - `Alt+D` / `Alt+Delete` - Delete word forward
228
229
  - `Ctrl+A` / `Ctrl+E` - Line start/end
230
+ - `Ctrl+-` - Undo last edit
229
231
  - Arrow keys, Backspace, Delete work as expected
230
232
 
231
233
  ### Markdown
@@ -266,7 +268,8 @@ const md = new Markdown(
266
268
  1, // paddingX
267
269
  1, // paddingY
268
270
  theme, // MarkdownTheme
269
- defaultStyle // optional DefaultTextStyle
271
+ defaultStyle, // optional DefaultTextStyle
272
+ 2 // optional code block indent (spaces)
270
273
  );
271
274
  md.setText("Updated markdown");
272
275
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "6.8.2",
3
+ "version": "6.8.3",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -306,6 +306,10 @@ export class Editor implements Component, Focusable {
306
306
  private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
307
307
  private historyStorage?: HistoryStorage;
308
308
 
309
+ // Undo stack for editor state changes
310
+ private undoStack: EditorState[] = [];
311
+ private suspendUndo = false;
312
+
309
313
  public onSubmit?: (text: string) => void;
310
314
  public onAltEnter?: (text: string) => void;
311
315
  public onChange?: (text: string) => void;
@@ -406,6 +410,7 @@ export class Editor implements Component, Focusable {
406
410
 
407
411
  /** Internal setText that doesn't reset history state - used by navigateHistory */
408
412
  private setTextInternal(text: string): void {
413
+ this.clearUndoStack();
409
414
  const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
410
415
  this.state.lines = lines.length === 0 ? [""] : lines;
411
416
  this.state.cursorLine = this.state.lines.length - 1;
@@ -671,6 +676,12 @@ export class Editor implements Component, Focusable {
671
676
  return;
672
677
  }
673
678
 
679
+ // Ctrl+- / Ctrl+_ - Undo last edit
680
+ if (matchesKey(data, "ctrl+-") || matchesKey(data, "ctrl+_")) {
681
+ this.applyUndo();
682
+ return;
683
+ }
684
+
674
685
  // Handle autocomplete special keys first (but don't block other input)
675
686
  if (this.isAutocompleting && this.autocompleteList) {
676
687
  // Escape - cancel autocomplete
@@ -794,7 +805,7 @@ export class Editor implements Component, Focusable {
794
805
  this.deleteWordBackwards();
795
806
  }
796
807
  // Option/Alt+D - Delete word forwards
797
- else if (matchesKey(data, "alt+d")) {
808
+ else if (matchesKey(data, "alt+d") || matchesKey(data, "alt+delete")) {
798
809
  this.deleteWordForwards();
799
810
  }
800
811
  // Ctrl+Y - Yank from kill ring
@@ -857,6 +868,7 @@ export class Editor implements Component, Focusable {
857
868
  cursorLine: 0,
858
869
  cursorCol: 0,
859
870
  };
871
+ this.clearUndoStack();
860
872
  this.pastes.clear();
861
873
  this.pasteCounter = 0;
862
874
  this.historyIndex = -1; // Exit history browsing mode
@@ -1059,6 +1071,7 @@ export class Editor implements Component, Focusable {
1059
1071
  insertText(text: string): void {
1060
1072
  this.historyIndex = -1;
1061
1073
  this.resetKillSequence();
1074
+ this.recordUndoState();
1062
1075
 
1063
1076
  const line = this.state.lines[this.state.cursorLine] || "";
1064
1077
  const before = line.slice(0, this.state.cursorCol);
@@ -1076,6 +1089,7 @@ export class Editor implements Component, Focusable {
1076
1089
  private insertCharacter(char: string): void {
1077
1090
  this.historyIndex = -1; // Exit history browsing mode
1078
1091
  this.resetKillSequence();
1092
+ this.recordUndoState();
1079
1093
 
1080
1094
  const line = this.state.lines[this.state.cursorLine] || "";
1081
1095
 
@@ -1126,107 +1140,105 @@ export class Editor implements Component, Focusable {
1126
1140
  private handlePaste(pastedText: string): void {
1127
1141
  this.historyIndex = -1; // Exit history browsing mode
1128
1142
  this.resetKillSequence();
1143
+ this.recordUndoState();
1129
1144
 
1130
- // Clean the pasted text
1131
- const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1145
+ this.withUndoSuspended(() => {
1146
+ // Clean the pasted text
1147
+ const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1132
1148
 
1133
- // Convert tabs to spaces (4 spaces per tab)
1134
- const tabExpandedText = cleanText.replace(/\t/g, " ");
1149
+ // Convert tabs to spaces (4 spaces per tab)
1150
+ const tabExpandedText = cleanText.replace(/\t/g, " ");
1135
1151
 
1136
- // Filter out non-printable characters except newlines
1137
- let filteredText = tabExpandedText
1138
- .split("")
1139
- .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
1140
- .join("");
1152
+ // Filter out non-printable characters except newlines
1153
+ let filteredText = tabExpandedText
1154
+ .split("")
1155
+ .filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
1156
+ .join("");
1141
1157
 
1142
- // If pasting a file path (starts with /, ~, or .) and the character before
1143
- // the cursor is a word character, prepend a space for better readability
1144
- if (/^[/~.]/.test(filteredText)) {
1145
- const currentLine = this.state.lines[this.state.cursorLine] || "";
1146
- const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : "";
1147
- if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1148
- filteredText = ` ${filteredText}`;
1158
+ // If pasting a file path (starts with /, ~, or .) and the character before
1159
+ // the cursor is a word character, prepend a space for better readability
1160
+ if (/^[/~.]/.test(filteredText)) {
1161
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1162
+ const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : "";
1163
+ if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
1164
+ filteredText = ` ${filteredText}`;
1165
+ }
1149
1166
  }
1150
- }
1151
1167
 
1152
- // Split into lines
1153
- const pastedLines = filteredText.split("\n");
1154
-
1155
- // Check if this is a large paste (> 10 lines or > 1000 characters)
1156
- const totalChars = filteredText.length;
1157
- if (pastedLines.length > 10 || totalChars > 1000) {
1158
- // Store the paste and insert a marker
1159
- this.pasteCounter++;
1160
- const pasteId = this.pasteCounter;
1161
- this.pastes.set(pasteId, filteredText);
1162
-
1163
- // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
1164
- const marker =
1165
- pastedLines.length > 10
1166
- ? `[paste #${pasteId} +${pastedLines.length} lines]`
1167
- : `[paste #${pasteId} ${totalChars} chars]`;
1168
- for (const char of marker) {
1169
- this.insertCharacter(char);
1170
- }
1168
+ // Split into lines
1169
+ const pastedLines = filteredText.split("\n");
1171
1170
 
1172
- return;
1173
- }
1171
+ // Check if this is a large paste (> 10 lines or > 1000 characters)
1172
+ const totalChars = filteredText.length;
1173
+ if (pastedLines.length > 10 || totalChars > 1000) {
1174
+ // Store the paste and insert a marker
1175
+ this.pasteCounter++;
1176
+ const pasteId = this.pasteCounter;
1177
+ this.pastes.set(pasteId, filteredText);
1178
+
1179
+ // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
1180
+ const marker =
1181
+ pastedLines.length > 10
1182
+ ? `[paste #${pasteId} +${pastedLines.length} lines]`
1183
+ : `[paste #${pasteId} ${totalChars} chars]`;
1184
+ this.insertTextAtCursor(marker);
1174
1185
 
1175
- if (pastedLines.length === 1) {
1176
- // Single line - just insert each character
1177
- const text = pastedLines[0] || "";
1178
- for (const char of text) {
1179
- this.insertCharacter(char);
1186
+ return;
1180
1187
  }
1181
1188
 
1182
- return;
1183
- }
1189
+ if (pastedLines.length === 1) {
1190
+ const text = pastedLines[0] || "";
1191
+ this.insertTextAtCursor(text);
1192
+ return;
1193
+ }
1184
1194
 
1185
- // Multi-line paste - be very careful with array manipulation
1186
- const currentLine = this.state.lines[this.state.cursorLine] || "";
1187
- const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1188
- const afterCursor = currentLine.slice(this.state.cursorCol);
1195
+ // Multi-line paste - be very careful with array manipulation
1196
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1197
+ const beforeCursor = currentLine.slice(0, this.state.cursorCol);
1198
+ const afterCursor = currentLine.slice(this.state.cursorCol);
1189
1199
 
1190
- // Build the new lines array step by step
1191
- const newLines: string[] = [];
1200
+ // Build the new lines array step by step
1201
+ const newLines: string[] = [];
1192
1202
 
1193
- // Add all lines before current line
1194
- for (let i = 0; i < this.state.cursorLine; i++) {
1195
- newLines.push(this.state.lines[i] || "");
1196
- }
1203
+ // Add all lines before current line
1204
+ for (let i = 0; i < this.state.cursorLine; i++) {
1205
+ newLines.push(this.state.lines[i] || "");
1206
+ }
1197
1207
 
1198
- // Add the first pasted line merged with before cursor text
1199
- newLines.push(beforeCursor + (pastedLines[0] || ""));
1208
+ // Add the first pasted line merged with before cursor text
1209
+ newLines.push(beforeCursor + (pastedLines[0] || ""));
1200
1210
 
1201
- // Add all middle pasted lines
1202
- for (let i = 1; i < pastedLines.length - 1; i++) {
1203
- newLines.push(pastedLines[i] || "");
1204
- }
1211
+ // Add all middle pasted lines
1212
+ for (let i = 1; i < pastedLines.length - 1; i++) {
1213
+ newLines.push(pastedLines[i] || "");
1214
+ }
1205
1215
 
1206
- // Add the last pasted line with after cursor text
1207
- newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
1216
+ // Add the last pasted line with after cursor text
1217
+ newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
1208
1218
 
1209
- // Add all lines after current line
1210
- for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
1211
- newLines.push(this.state.lines[i] || "");
1212
- }
1219
+ // Add all lines after current line
1220
+ for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
1221
+ newLines.push(this.state.lines[i] || "");
1222
+ }
1213
1223
 
1214
- // Replace the entire lines array
1215
- this.state.lines = newLines;
1224
+ // Replace the entire lines array
1225
+ this.state.lines = newLines;
1216
1226
 
1217
- // Update cursor position to end of pasted content
1218
- this.state.cursorLine += pastedLines.length - 1;
1219
- this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
1227
+ // Update cursor position to end of pasted content
1228
+ this.state.cursorLine += pastedLines.length - 1;
1229
+ this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
1220
1230
 
1221
- // Notify of change
1222
- if (this.onChange) {
1223
- this.onChange(this.getText());
1224
- }
1231
+ // Notify of change
1232
+ if (this.onChange) {
1233
+ this.onChange(this.getText());
1234
+ }
1235
+ });
1225
1236
  }
1226
1237
 
1227
1238
  private addNewLine(): void {
1228
1239
  this.historyIndex = -1; // Exit history browsing mode
1229
1240
  this.resetKillSequence();
1241
+ this.recordUndoState();
1230
1242
 
1231
1243
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1232
1244
 
@@ -1249,6 +1261,7 @@ export class Editor implements Component, Focusable {
1249
1261
  private handleBackspace(): void {
1250
1262
  this.historyIndex = -1; // Exit history browsing mode
1251
1263
  this.resetKillSequence();
1264
+ this.recordUndoState();
1252
1265
 
1253
1266
  if (this.state.cursorCol > 0) {
1254
1267
  // Delete grapheme before cursor (handles emojis, combining characters, etc.)
@@ -1314,6 +1327,73 @@ export class Editor implements Component, Focusable {
1314
1327
  this.lastKillWasKillCommand = false;
1315
1328
  }
1316
1329
 
1330
+ private clearUndoStack(): void {
1331
+ this.undoStack = [];
1332
+ }
1333
+
1334
+ private withUndoSuspended<T>(fn: () => T): T {
1335
+ const wasSuspended = this.suspendUndo;
1336
+ this.suspendUndo = true;
1337
+ try {
1338
+ return fn();
1339
+ } finally {
1340
+ this.suspendUndo = wasSuspended;
1341
+ }
1342
+ }
1343
+
1344
+ private recordUndoState(): void {
1345
+ if (this.suspendUndo) return;
1346
+ const snapshot: EditorState = {
1347
+ lines: [...this.state.lines],
1348
+ cursorLine: this.state.cursorLine,
1349
+ cursorCol: this.state.cursorCol,
1350
+ };
1351
+
1352
+ const last = this.undoStack[this.undoStack.length - 1];
1353
+ if (last) {
1354
+ const sameLines =
1355
+ last.cursorLine === snapshot.cursorLine &&
1356
+ last.cursorCol === snapshot.cursorCol &&
1357
+ last.lines.length === snapshot.lines.length &&
1358
+ last.lines.every((line, index) => line === snapshot.lines[index]);
1359
+ if (sameLines) return;
1360
+ }
1361
+
1362
+ this.undoStack.push(snapshot);
1363
+ if (this.undoStack.length > 200) {
1364
+ this.undoStack.shift();
1365
+ }
1366
+ }
1367
+
1368
+ private applyUndo(): void {
1369
+ const snapshot = this.undoStack.pop();
1370
+ if (!snapshot) return;
1371
+
1372
+ this.historyIndex = -1;
1373
+ this.resetKillSequence();
1374
+ this.state = {
1375
+ lines: [...snapshot.lines],
1376
+ cursorLine: snapshot.cursorLine,
1377
+ cursorCol: snapshot.cursorCol,
1378
+ };
1379
+
1380
+ if (this.onChange) {
1381
+ this.onChange(this.getText());
1382
+ }
1383
+
1384
+ if (this.isAutocompleting) {
1385
+ this.updateAutocomplete();
1386
+ } else {
1387
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1388
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1389
+ if (textBeforeCursor.trimStart().startsWith("/")) {
1390
+ this.tryTriggerAutocomplete();
1391
+ } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
1392
+ this.tryTriggerAutocomplete();
1393
+ }
1394
+ }
1395
+ }
1396
+
1317
1397
  private recordKill(text: string, direction: "forward" | "backward"): void {
1318
1398
  if (!text) return;
1319
1399
  if (this.lastKillWasKillCommand && this.killRing.length > 0) {
@@ -1331,6 +1411,7 @@ export class Editor implements Component, Focusable {
1331
1411
  private insertTextAtCursor(text: string): void {
1332
1412
  this.historyIndex = -1;
1333
1413
  this.resetKillSequence();
1414
+ this.recordUndoState();
1334
1415
 
1335
1416
  const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1336
1417
  const lines = normalized.split("\n");
@@ -1378,6 +1459,7 @@ export class Editor implements Component, Focusable {
1378
1459
 
1379
1460
  private deleteToStartOfLine(): void {
1380
1461
  this.historyIndex = -1; // Exit history browsing mode
1462
+ this.recordUndoState();
1381
1463
 
1382
1464
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1383
1465
  let deletedText = "";
@@ -1406,6 +1488,7 @@ export class Editor implements Component, Focusable {
1406
1488
 
1407
1489
  private deleteToEndOfLine(): void {
1408
1490
  this.historyIndex = -1; // Exit history browsing mode
1491
+ this.recordUndoState();
1409
1492
 
1410
1493
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1411
1494
  let deletedText = "";
@@ -1431,6 +1514,7 @@ export class Editor implements Component, Focusable {
1431
1514
 
1432
1515
  private deleteWordBackwards(): void {
1433
1516
  this.historyIndex = -1; // Exit history browsing mode
1517
+ this.recordUndoState();
1434
1518
 
1435
1519
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1436
1520
 
@@ -1464,6 +1548,7 @@ export class Editor implements Component, Focusable {
1464
1548
 
1465
1549
  private deleteWordForwards(): void {
1466
1550
  this.historyIndex = -1; // Exit history browsing mode
1551
+ this.recordUndoState();
1467
1552
 
1468
1553
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1469
1554
 
@@ -1493,6 +1578,7 @@ export class Editor implements Component, Focusable {
1493
1578
  private handleForwardDelete(): void {
1494
1579
  this.historyIndex = -1; // Exit history browsing mode
1495
1580
  this.resetKillSequence();
1581
+ this.recordUndoState();
1496
1582
 
1497
1583
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1498
1584
 
@@ -127,6 +127,11 @@ export class Input implements Component, Focusable {
127
127
  return;
128
128
  }
129
129
 
130
+ if (kb.matches(data, "deleteWordForward")) {
131
+ this.deleteWordForwards();
132
+ return;
133
+ }
134
+
130
135
  if (kb.matches(data, "deleteToLineStart")) {
131
136
  this.value = this.value.slice(this.cursor);
132
137
  this.cursor = 0;
@@ -205,6 +210,19 @@ export class Input implements Component, Focusable {
205
210
  this.cursor = deleteFrom;
206
211
  }
207
212
 
213
+ private deleteWordForwards(): void {
214
+ if (this.cursor >= this.value.length) {
215
+ return;
216
+ }
217
+
218
+ const oldCursor = this.cursor;
219
+ this.moveWordForwards();
220
+ const deleteTo = this.cursor;
221
+ this.cursor = oldCursor;
222
+
223
+ this.value = this.value.slice(0, oldCursor) + this.value.slice(deleteTo);
224
+ }
225
+
208
226
  private moveWordBackwards(): void {
209
227
  if (this.cursor === 0) {
210
228
  return;
@@ -52,6 +52,8 @@ export class Markdown implements Component {
52
52
  private defaultTextStyle?: DefaultTextStyle;
53
53
  private theme: MarkdownTheme;
54
54
  private defaultStylePrefix?: string;
55
+ /** Number of spaces used to indent code block content. */
56
+ private codeBlockIndent: number;
55
57
 
56
58
  // Cache for rendered output
57
59
  private cachedText?: string;
@@ -64,12 +66,14 @@ export class Markdown implements Component {
64
66
  paddingY: number,
65
67
  theme: MarkdownTheme,
66
68
  defaultTextStyle?: DefaultTextStyle,
69
+ codeBlockIndent: number = 2,
67
70
  ) {
68
71
  this.text = text;
69
72
  this.paddingX = paddingX;
70
73
  this.paddingY = paddingY;
71
74
  this.theme = theme;
72
75
  this.defaultTextStyle = defaultTextStyle;
76
+ this.codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
73
77
  }
74
78
 
75
79
  setText(text: string): void {
@@ -263,17 +267,18 @@ export class Markdown implements Component {
263
267
  }
264
268
 
265
269
  case "code": {
270
+ const codeIndent = " ".repeat(this.codeBlockIndent);
266
271
  lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
267
272
  if (this.theme.highlightCode) {
268
273
  const highlightedLines = this.theme.highlightCode(token.text, token.lang);
269
274
  for (const hlLine of highlightedLines) {
270
- lines.push(` ${hlLine}`);
275
+ lines.push(`${codeIndent}${hlLine}`);
271
276
  }
272
277
  } else {
273
278
  // Split code by newlines and style each line
274
279
  const codeLines = token.text.split("\n");
275
280
  for (const codeLine of codeLines) {
276
- lines.push(` ${this.theme.codeBlock(codeLine)}`);
281
+ lines.push(`${codeIndent}${this.theme.codeBlock(codeLine)}`);
277
282
  }
278
283
  }
279
284
  lines.push(this.theme.codeBlockBorder("```"));
@@ -493,16 +498,17 @@ export class Markdown implements Component {
493
498
  lines.push(text);
494
499
  } else if (token.type === "code") {
495
500
  // Code block in list item
501
+ const codeIndent = " ".repeat(this.codeBlockIndent);
496
502
  lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
497
503
  if (this.theme.highlightCode) {
498
504
  const highlightedLines = this.theme.highlightCode(token.text, token.lang);
499
505
  for (const hlLine of highlightedLines) {
500
- lines.push(` ${hlLine}`);
506
+ lines.push(`${codeIndent}${hlLine}`);
501
507
  }
502
508
  } else {
503
509
  const codeLines = token.text.split("\n");
504
510
  for (const codeLine of codeLines) {
505
- lines.push(` ${this.theme.codeBlock(codeLine)}`);
511
+ lines.push(`${codeIndent}${this.theme.codeBlock(codeLine)}`);
506
512
  }
507
513
  }
508
514
  lines.push(this.theme.codeBlockBorder("```"));
package/src/fuzzy.ts CHANGED
@@ -9,10 +9,9 @@ export interface FuzzyMatch {
9
9
  score: number;
10
10
  }
11
11
 
12
- export function fuzzyMatch(query: string, text: string): FuzzyMatch {
13
- const queryLower = query.toLowerCase();
14
- const textLower = text.toLowerCase();
12
+ const ALPHANUMERIC_SWAP_PENALTY = 5;
15
13
 
14
+ function scoreMatch(queryLower: string, textLower: string): FuzzyMatch {
16
15
  if (queryLower.length === 0) {
17
16
  return { matches: true, score: 0 };
18
17
  }
@@ -62,6 +61,43 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
62
61
  return { matches: true, score };
63
62
  }
64
63
 
64
+ function buildAlphanumericSwapQueries(queryLower: string): string[] {
65
+ const variants = new Set<string>();
66
+ for (let i = 0; i < queryLower.length - 1; i++) {
67
+ const current = queryLower[i];
68
+ const next = queryLower[i + 1];
69
+ const isAlphaNumSwap =
70
+ (current && /[a-z]/.test(current) && next && /\d/.test(next)) ||
71
+ (current && /\d/.test(current) && next && /[a-z]/.test(next));
72
+ if (!isAlphaNumSwap) continue;
73
+ const swapped = queryLower.slice(0, i) + next + current + queryLower.slice(i + 2);
74
+ variants.add(swapped);
75
+ }
76
+ return [...variants];
77
+ }
78
+
79
+ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
80
+ const queryLower = query.toLowerCase();
81
+ const textLower = text.toLowerCase();
82
+
83
+ const direct = scoreMatch(queryLower, textLower);
84
+ if (direct.matches) {
85
+ return direct;
86
+ }
87
+
88
+ let bestSwap: FuzzyMatch | null = null;
89
+ for (const variant of buildAlphanumericSwapQueries(queryLower)) {
90
+ const match = scoreMatch(variant, textLower);
91
+ if (!match.matches) continue;
92
+ const score = match.score + ALPHANUMERIC_SWAP_PENALTY;
93
+ if (!bestSwap || score < bestSwap.score) {
94
+ bestSwap = { matches: true, score };
95
+ }
96
+ }
97
+
98
+ return bestSwap ?? direct;
99
+ }
100
+
65
101
  /**
66
102
  * Filter and sort items by fuzzy match quality (best matches first).
67
103
  * Supports space-separated tokens: all tokens must match.
@@ -17,6 +17,7 @@ export type EditorAction =
17
17
  | "deleteCharBackward"
18
18
  | "deleteCharForward"
19
19
  | "deleteWordBackward"
20
+ | "deleteWordForward"
20
21
  | "deleteToLineStart"
21
22
  | "deleteToLineEnd"
22
23
  // Text input
@@ -60,6 +61,7 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
60
61
  deleteCharBackward: "backspace",
61
62
  deleteCharForward: "delete",
62
63
  deleteWordBackward: ["ctrl+w", "alt+backspace"],
64
+ deleteWordForward: ["alt+delete", "alt+d"],
63
65
  deleteToLineStart: "ctrl+u",
64
66
  deleteToLineEnd: "ctrl+k",
65
67
  // Text input
@@ -100,6 +102,8 @@ const SHIFTED_SYMBOL_KEYS = new Set<string>([
100
102
  "~",
101
103
  ]);
102
104
 
105
+ const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
106
+
103
107
  /**
104
108
  * Manages keybindings for the editor.
105
109
  */
@@ -117,14 +121,20 @@ export class EditorKeybindingsManager {
117
121
  // Start with defaults
118
122
  for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) {
119
123
  const keyArray = Array.isArray(keys) ? keys : [keys];
120
- this.actionToKeys.set(action as EditorAction, [...keyArray]);
124
+ this.actionToKeys.set(
125
+ action as EditorAction,
126
+ keyArray.map((key) => normalizeKeyId(key as KeyId)),
127
+ );
121
128
  }
122
129
 
123
130
  // Override with user config
124
131
  for (const [action, keys] of Object.entries(config)) {
125
132
  if (keys === undefined) continue;
126
133
  const keyArray = Array.isArray(keys) ? keys : [keys];
127
- this.actionToKeys.set(action as EditorAction, keyArray);
134
+ this.actionToKeys.set(
135
+ action as EditorAction,
136
+ keyArray.map((key) => normalizeKeyId(key as KeyId)),
137
+ );
128
138
  }
129
139
  }
130
140
 
package/src/keys.ts CHANGED
@@ -290,6 +290,23 @@ const SYMBOL_KEYS = new Set([
290
290
  "?",
291
291
  ]);
292
292
 
293
+ const CTRL_SYMBOL_MAP: Record<string, string> = {
294
+ "@": "\x00",
295
+ "[": "\x1b",
296
+ "\\": "\x1c",
297
+ "]": "\x1d",
298
+ "^": "\x1e",
299
+ _: "\x1f",
300
+ "-": "\x1f",
301
+ } as const;
302
+
303
+ const CTRL_SYMBOL_CODES: Record<number, KeyId> = {
304
+ 28: "ctrl+\\",
305
+ 29: "ctrl+]",
306
+ 30: "ctrl+^",
307
+ 31: "ctrl+_",
308
+ } as const;
309
+
293
310
  const MODIFIERS = {
294
311
  shift: 1,
295
312
  alt: 2,
@@ -966,6 +983,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
966
983
  // Handle single letter keys (a-z) and some symbols
967
984
  if (key.length === 1 && ((key >= "a" && key <= "z") || SYMBOL_KEYS.has(key))) {
968
985
  const codepoint = key.charCodeAt(0);
986
+ const isLetterKey = key >= "a" && key <= "z";
969
987
 
970
988
  if (ctrl && alt && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") {
971
989
  return data === `\x1b${rawCtrlChar(key)}`;
@@ -977,9 +995,16 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
977
995
  }
978
996
 
979
997
  if (ctrl && !shift && !alt) {
998
+ if (!isLetterKey) {
999
+ const legacyCtrl = CTRL_SYMBOL_MAP[key];
1000
+ if (legacyCtrl && data === legacyCtrl) return true;
1001
+ if (matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)) return true;
1002
+ return matchesKittySequence(data, codepoint, MODIFIERS.ctrl);
1003
+ }
980
1004
  const raw = rawCtrlChar(key);
981
1005
  if (data === raw) return true;
982
1006
  if (data.length > 0 && data.charCodeAt(0) === raw.charCodeAt(0)) return true;
1007
+ if (matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)) return true;
983
1008
  return matchesKittySequence(data, codepoint, MODIFIERS.ctrl);
984
1009
  }
985
1010
 
@@ -1096,6 +1121,10 @@ export function parseKey(data: string): string | undefined {
1096
1121
  // Raw Ctrl+letter
1097
1122
  if (data.length === 1) {
1098
1123
  const code = data.charCodeAt(0);
1124
+ const ctrlSymbol = CTRL_SYMBOL_CODES[code];
1125
+ if (ctrlSymbol) {
1126
+ return ctrlSymbol;
1127
+ }
1099
1128
  if (code >= 1 && code <= 26) {
1100
1129
  return `ctrl+${String.fromCharCode(code + 96)}`;
1101
1130
  }