@oh-my-pi/pi-tui 6.8.2 → 6.8.4
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 +4 -1
- package/package.json +1 -1
- package/src/components/editor.ts +163 -77
- package/src/components/input.ts +18 -0
- package/src/components/markdown.ts +10 -4
- package/src/fuzzy.ts +39 -3
- package/src/keybindings.ts +12 -2
- package/src/keys.ts +29 -0
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
package/src/components/editor.ts
CHANGED
|
@@ -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
|
-
|
|
1131
|
-
|
|
1145
|
+
this.withUndoSuspended(() => {
|
|
1146
|
+
// Clean the pasted text
|
|
1147
|
+
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1132
1148
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1149
|
+
// Convert tabs to spaces (4 spaces per tab)
|
|
1150
|
+
const tabExpandedText = cleanText.replace(/\t/g, " ");
|
|
1135
1151
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
1153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1183
|
-
|
|
1189
|
+
if (pastedLines.length === 1) {
|
|
1190
|
+
const text = pastedLines[0] || "";
|
|
1191
|
+
this.insertTextAtCursor(text);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1184
1194
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
1200
|
+
// Build the new lines array step by step
|
|
1201
|
+
const newLines: string[] = [];
|
|
1192
1202
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
-
|
|
1199
|
-
|
|
1208
|
+
// Add the first pasted line merged with before cursor text
|
|
1209
|
+
newLines.push(beforeCursor + (pastedLines[0] || ""));
|
|
1200
1210
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
1216
|
+
// Add the last pasted line with after cursor text
|
|
1217
|
+
newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
|
|
1208
1218
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
1224
|
+
// Replace the entire lines array
|
|
1225
|
+
this.state.lines = newLines;
|
|
1216
1226
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
|
package/src/components/input.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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.
|
package/src/keybindings.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
}
|