@leohenon/pi-vim 0.0.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 +129 -0
- package/assets/demo.gif +0 -0
- package/index.ts +1710 -0
- package/package.json +39 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
3
|
+
|
|
4
|
+
type Mode = "normal" | "insert" | "replace" | "visual" | "visual-line";
|
|
5
|
+
type Pending = "d" | "c" | "y" | "f" | "F" | "t" | "T" | "r" | undefined;
|
|
6
|
+
type LastFind = { char: string; forward: boolean; till: boolean } | undefined;
|
|
7
|
+
type Cursor = { line: number; col: number };
|
|
8
|
+
type CustomEditorArgs = ConstructorParameters<typeof CustomEditor>;
|
|
9
|
+
|
|
10
|
+
type InternalEditor = {
|
|
11
|
+
state: {
|
|
12
|
+
cursorLine: number;
|
|
13
|
+
lines?: string[];
|
|
14
|
+
cursorCol?: number;
|
|
15
|
+
};
|
|
16
|
+
setCursorCol(col: number): void;
|
|
17
|
+
onChange?: (text: string) => void;
|
|
18
|
+
preferredVisualCol?: number | null;
|
|
19
|
+
historyIndex?: number;
|
|
20
|
+
lastAction?: string | null;
|
|
21
|
+
paddingX?: number;
|
|
22
|
+
scrollOffset?: number;
|
|
23
|
+
borderColor?: (text: string) => string;
|
|
24
|
+
layoutText?: (contentWidth: number) => Array<{ text: string; hasCursor: boolean; cursorPos?: number }>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type EditorSnapshot = {
|
|
28
|
+
text: string;
|
|
29
|
+
cursor: Cursor;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
33
|
+
|
|
34
|
+
function clamp(value: number, min: number, max: number): number {
|
|
35
|
+
return Math.max(min, Math.min(max, value));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sanitizeStatusText(text: string): string {
|
|
39
|
+
return text
|
|
40
|
+
.replace(/[\r\n\t]/g, " ")
|
|
41
|
+
.replace(/ +/g, " ")
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatTokens(count: number): string {
|
|
46
|
+
if (count < 1000) return count.toString();
|
|
47
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
48
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
49
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
50
|
+
return `${Math.round(count / 1000000)}M`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isWord(char: string | undefined): boolean {
|
|
54
|
+
return !!char && /[A-Za-z0-9_]/.test(char);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isBigWord(char: string | undefined): boolean {
|
|
58
|
+
return !!char && !/\s/.test(char);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function lineStart(text: string, offset: number): number {
|
|
62
|
+
if (offset <= 0) return 0;
|
|
63
|
+
const bounded = Math.min(offset, text.length);
|
|
64
|
+
const index = text.lastIndexOf("\n", bounded - 1);
|
|
65
|
+
return index === -1 ? 0 : index + 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function lineEnd(text: string, offset: number): number {
|
|
69
|
+
const bounded = Math.min(Math.max(offset, 0), text.length);
|
|
70
|
+
const index = text.indexOf("\n", bounded);
|
|
71
|
+
return index === -1 ? text.length : index;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function lineLast(text: string, offset: number): number {
|
|
75
|
+
const start = lineStart(text, offset);
|
|
76
|
+
const end = lineEnd(text, offset);
|
|
77
|
+
return end > start ? end - 1 : start;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function prevLineStart(text: string, offset: number): number | undefined {
|
|
81
|
+
const start = lineStart(text, offset);
|
|
82
|
+
if (start === 0) return undefined;
|
|
83
|
+
return lineStart(text, start - 1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function nextLineStart(text: string, offset: number): number | undefined {
|
|
87
|
+
const end = lineEnd(text, offset);
|
|
88
|
+
if (end >= text.length) return undefined;
|
|
89
|
+
return end + 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function moveUp(text: string, offset: number): number {
|
|
93
|
+
const currentStart = lineStart(text, offset);
|
|
94
|
+
const targetStart = prevLineStart(text, offset);
|
|
95
|
+
if (targetStart === undefined) return offset;
|
|
96
|
+
const targetLast = lineLast(text, targetStart);
|
|
97
|
+
const col = offset - currentStart;
|
|
98
|
+
return Math.min(targetStart + col, targetLast);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function moveDown(text: string, offset: number): number {
|
|
102
|
+
const currentStart = lineStart(text, offset);
|
|
103
|
+
const targetStart = nextLineStart(text, offset);
|
|
104
|
+
if (targetStart === undefined) return offset;
|
|
105
|
+
const targetLast = lineLast(text, targetStart);
|
|
106
|
+
const col = offset - currentStart;
|
|
107
|
+
return Math.min(targetStart + col, targetLast);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function nextWordStart(text: string, offset: number, big: boolean): number {
|
|
111
|
+
const match = big ? isBigWord : isWord;
|
|
112
|
+
let pos = offset;
|
|
113
|
+
|
|
114
|
+
if (pos < text.length && match(text[pos])) {
|
|
115
|
+
while (pos < text.length && match(text[pos])) pos++;
|
|
116
|
+
}
|
|
117
|
+
while (pos < text.length && !match(text[pos])) pos++;
|
|
118
|
+
return pos;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function prevWordStart(text: string, offset: number, big: boolean): number {
|
|
122
|
+
const match = big ? isBigWord : isWord;
|
|
123
|
+
let pos = offset;
|
|
124
|
+
|
|
125
|
+
while (pos > 0 && !match(text[pos - 1])) pos--;
|
|
126
|
+
while (pos > 0 && match(text[pos - 1])) pos--;
|
|
127
|
+
return pos;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function wordEnd(text: string, offset: number, big: boolean): number {
|
|
131
|
+
if (text.length === 0) return 0;
|
|
132
|
+
|
|
133
|
+
const match = big ? isBigWord : isWord;
|
|
134
|
+
let pos = offset;
|
|
135
|
+
if (pos >= text.length) pos = text.length - 1;
|
|
136
|
+
|
|
137
|
+
if (match(text[pos]) && (pos + 1 >= text.length || !match(text[pos + 1]))) {
|
|
138
|
+
pos++;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
while (pos < text.length && !match(text[pos])) pos++;
|
|
142
|
+
if (pos >= text.length) return text.length - 1;
|
|
143
|
+
|
|
144
|
+
while (pos + 1 < text.length && match(text[pos + 1])) pos++;
|
|
145
|
+
return pos;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function firstNonWhitespace(text: string, offset: number): number {
|
|
149
|
+
const start = lineStart(text, offset);
|
|
150
|
+
const end = lineEnd(text, offset);
|
|
151
|
+
let pos = start;
|
|
152
|
+
while (pos < end && /\s/.test(text[pos] ?? "")) pos++;
|
|
153
|
+
return pos;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isBlankLine(text: string, offset: number): boolean {
|
|
157
|
+
const start = lineStart(text, offset);
|
|
158
|
+
const end = lineEnd(text, offset);
|
|
159
|
+
return /^\s*$/.test(text.slice(start, end));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function paragraphForward(text: string, offset: number): number {
|
|
163
|
+
let pos = lineEnd(text, offset);
|
|
164
|
+
while (pos < text.length) {
|
|
165
|
+
pos += 1;
|
|
166
|
+
if (pos >= text.length) return lineStart(text, text.length);
|
|
167
|
+
if (!isBlankLine(text, pos) && (pos === 0 || isBlankLine(text, pos - 1))) return pos;
|
|
168
|
+
pos = lineEnd(text, pos);
|
|
169
|
+
}
|
|
170
|
+
return offset;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function paragraphBackward(text: string, offset: number): number {
|
|
174
|
+
let pos = lineStart(text, offset);
|
|
175
|
+
while (pos > 0) {
|
|
176
|
+
pos = lineStart(text, pos - 1);
|
|
177
|
+
if (!isBlankLine(text, pos) && (pos === 0 || isBlankLine(text, pos - 1))) return pos;
|
|
178
|
+
}
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function totalLength(lines: string[]): number {
|
|
183
|
+
if (lines.length === 0) return 0;
|
|
184
|
+
return lines.reduce((sum, line) => sum + line.length, 0) + lines.length - 1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function cursorToOffset(lines: string[], cursor: Cursor): number {
|
|
188
|
+
let offset = 0;
|
|
189
|
+
for (let i = 0; i < cursor.line; i++) {
|
|
190
|
+
offset += (lines[i] ?? "").length + 1;
|
|
191
|
+
}
|
|
192
|
+
return offset + cursor.col;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function offsetToCursor(lines: string[], offset: number): Cursor {
|
|
196
|
+
const boundedOffset = clamp(offset, 0, totalLength(lines));
|
|
197
|
+
let remaining = boundedOffset;
|
|
198
|
+
|
|
199
|
+
for (let line = 0; line < lines.length; line++) {
|
|
200
|
+
const current = lines[line] ?? "";
|
|
201
|
+
if (remaining <= current.length) {
|
|
202
|
+
return { line, col: remaining };
|
|
203
|
+
}
|
|
204
|
+
remaining -= current.length;
|
|
205
|
+
if (line < lines.length - 1) remaining -= 1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const lastLine = Math.max(0, lines.length - 1);
|
|
209
|
+
return { line: lastLine, col: (lines[lastLine] ?? "").length };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function nextGraphemeOffset(text: string, offset: number): number {
|
|
213
|
+
if (offset >= text.length) return offset;
|
|
214
|
+
for (const segment of graphemeSegmenter.segment(text.slice(offset))) {
|
|
215
|
+
return offset + segment.segment.length;
|
|
216
|
+
}
|
|
217
|
+
return Math.min(offset + 1, text.length);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function prevGraphemeOffset(text: string, offset: number): number {
|
|
221
|
+
if (offset <= 0) return 0;
|
|
222
|
+
let previous = 0;
|
|
223
|
+
for (const segment of graphemeSegmenter.segment(text.slice(0, offset))) {
|
|
224
|
+
previous = segment.index;
|
|
225
|
+
}
|
|
226
|
+
return previous;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function replaceRange(text: string, start: number, end: number, replacement = ""): string {
|
|
230
|
+
return text.slice(0, start) + replacement + text.slice(end);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
class VimModeEditor extends CustomEditor {
|
|
234
|
+
private mode: Mode = "insert";
|
|
235
|
+
private pending: Pending;
|
|
236
|
+
private pendingTextObject?: "i" | "a";
|
|
237
|
+
private pendingFindOp?: { op: "d" | "c" | "y"; motion: "f" | "F" | "t" | "T"; count: number };
|
|
238
|
+
private visualAnchor?: number;
|
|
239
|
+
private flashRange?: { start: number; end: number; linewise: boolean };
|
|
240
|
+
private flashTimer?: ReturnType<typeof setTimeout>;
|
|
241
|
+
private count = "";
|
|
242
|
+
private pendingG = false;
|
|
243
|
+
private lastFind: LastFind;
|
|
244
|
+
private readonly redoStack: EditorSnapshot[] = [];
|
|
245
|
+
private unnamedRegister = "";
|
|
246
|
+
private unnamedRegisterType: "char" | "line" = "char";
|
|
247
|
+
|
|
248
|
+
constructor(
|
|
249
|
+
tui: CustomEditorArgs[0],
|
|
250
|
+
theme: CustomEditorArgs[1],
|
|
251
|
+
keybindings: CustomEditorArgs[2],
|
|
252
|
+
private onStatusChange: (mode: Mode, pending: string) => void,
|
|
253
|
+
) {
|
|
254
|
+
super(tui, theme, keybindings);
|
|
255
|
+
this.emitStatus();
|
|
256
|
+
setTimeout(() => this.updateCursorStyle(), 0);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private get editor(): InternalEditor {
|
|
260
|
+
return this as unknown as InternalEditor;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private getPendingDisplay(): string {
|
|
264
|
+
const count = this.count;
|
|
265
|
+
if (this.pendingFindOp) return `${this.pendingFindOp.op}${count}${this.pendingFindOp.motion}`;
|
|
266
|
+
if (this.pendingTextObject && this.pending) return `${this.pending}${count}${this.pendingTextObject}`;
|
|
267
|
+
if (this.pending) return `${this.pending}${count}`;
|
|
268
|
+
if (this.pendingG) return `g${count}`;
|
|
269
|
+
if (count) return count;
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private writeCursorShape(sequence: string): void {
|
|
274
|
+
try {
|
|
275
|
+
process.stdout.write(sequence);
|
|
276
|
+
} catch {}
|
|
277
|
+
try {
|
|
278
|
+
this.tui.terminal.write(sequence);
|
|
279
|
+
} catch {}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private updateCursorStyle(): void {
|
|
283
|
+
const textEntry = this.mode === "insert" || this.mode === "replace";
|
|
284
|
+
const sequence = this.mode === "replace" ? "\x1b[3 q" : textEntry ? "\x1b[5 q" : "\x1b[2 q";
|
|
285
|
+
(this.tui as unknown as { setShowHardwareCursor(enabled: boolean): void }).setShowHardwareCursor(textEntry);
|
|
286
|
+
this.writeCursorShape(sequence);
|
|
287
|
+
setTimeout(() => this.writeCursorShape(sequence), 0);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private emitStatus(): void {
|
|
291
|
+
this.onStatusChange(this.mode, this.getPendingDisplay());
|
|
292
|
+
this.updateCursorStyle();
|
|
293
|
+
this.tui.requestRender();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private clearPending(): void {
|
|
297
|
+
this.pending = undefined;
|
|
298
|
+
this.pendingTextObject = undefined;
|
|
299
|
+
this.pendingFindOp = undefined;
|
|
300
|
+
this.pendingG = false;
|
|
301
|
+
this.count = "";
|
|
302
|
+
this.emitStatus();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private getVisualRange(): { start: number; end: number; linewise: boolean } | undefined {
|
|
306
|
+
if (this.visualAnchor === undefined) return undefined;
|
|
307
|
+
const current = this.getCurrentOffset();
|
|
308
|
+
if (this.mode === "visual-line") {
|
|
309
|
+
const start = Math.min(lineStart(this.getCurrentText(), this.visualAnchor), lineStart(this.getCurrentText(), current));
|
|
310
|
+
const end = Math.max(lineEnd(this.getCurrentText(), this.visualAnchor), lineEnd(this.getCurrentText(), current));
|
|
311
|
+
return { start, end: Math.min(end + 1, this.getCurrentText().length), linewise: true };
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
start: Math.min(this.visualAnchor, current),
|
|
315
|
+
end: Math.max(this.visualAnchor, current) + 1,
|
|
316
|
+
linewise: false,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private takeCount(defaultCount = 1): number {
|
|
321
|
+
const value = this.count ? Number.parseInt(this.count, 10) : defaultCount;
|
|
322
|
+
this.count = "";
|
|
323
|
+
return Number.isFinite(value) && value > 0 ? value : defaultCount;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private captureSnapshot(): EditorSnapshot {
|
|
327
|
+
const cursor = this.getCursor();
|
|
328
|
+
return {
|
|
329
|
+
text: this.getText(),
|
|
330
|
+
cursor: { line: cursor.line, col: cursor.col },
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private restoreSnapshot(snapshot: EditorSnapshot): void {
|
|
335
|
+
this.editor.state.lines = snapshot.text.split("\n");
|
|
336
|
+
this.editor.state.cursorLine = snapshot.cursor.line;
|
|
337
|
+
this.editor.setCursorCol(snapshot.cursor.col);
|
|
338
|
+
this.editor.preferredVisualCol = null;
|
|
339
|
+
this.editor.historyIndex = -1;
|
|
340
|
+
this.editor.lastAction = null;
|
|
341
|
+
this.editor.onChange?.(snapshot.text);
|
|
342
|
+
this.tui.requestRender();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private clearRedoStack(): void {
|
|
346
|
+
this.redoStack.length = 0;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private flashSelection(start: number, end: number, linewise = false): void {
|
|
350
|
+
if (this.flashTimer) clearTimeout(this.flashTimer);
|
|
351
|
+
this.flashRange = { start: Math.min(start, end), end: Math.max(start, end), linewise };
|
|
352
|
+
this.tui.requestRender();
|
|
353
|
+
this.flashTimer = setTimeout(() => {
|
|
354
|
+
this.flashRange = undefined;
|
|
355
|
+
this.flashTimer = undefined;
|
|
356
|
+
this.tui.requestRender();
|
|
357
|
+
}, 120);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private writeRegister(text: string, type: "char" | "line" = "char"): void {
|
|
361
|
+
this.unnamedRegister = text;
|
|
362
|
+
this.unnamedRegisterType = type;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private wordObjectRange(around: boolean): { start: number; end: number } | undefined {
|
|
366
|
+
const text = this.getCurrentText();
|
|
367
|
+
let start = this.getCurrentOffset();
|
|
368
|
+
if (!isWord(text[start])) {
|
|
369
|
+
if (isWord(text[start - 1])) start -= 1;
|
|
370
|
+
else return undefined;
|
|
371
|
+
}
|
|
372
|
+
while (start > 0 && isWord(text[start - 1])) start--;
|
|
373
|
+
let end = start;
|
|
374
|
+
while (end < text.length && isWord(text[end])) end++;
|
|
375
|
+
if (around) {
|
|
376
|
+
while (end < text.length && (text[end] === " " || text[end] === "\t")) end++;
|
|
377
|
+
}
|
|
378
|
+
return { start, end };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private delimitedObjectRange(char: string, around: boolean): { start: number; end: number } | undefined {
|
|
382
|
+
const pairs: Record<string, [string, string]> = {
|
|
383
|
+
'"': ['"', '"'],
|
|
384
|
+
"'": ["'", "'"],
|
|
385
|
+
"`": ["`", "`"],
|
|
386
|
+
"(": ["(", ")"],
|
|
387
|
+
")": ["(", ")"],
|
|
388
|
+
"[": ["[", "]"],
|
|
389
|
+
"]": ["[", "]"],
|
|
390
|
+
"{": ["{", "}"],
|
|
391
|
+
"}": ["{", "}"],
|
|
392
|
+
};
|
|
393
|
+
const pair = pairs[char];
|
|
394
|
+
if (!pair) return undefined;
|
|
395
|
+
const [open, close] = pair;
|
|
396
|
+
const text = this.getCurrentText();
|
|
397
|
+
if (text.length === 0) return undefined;
|
|
398
|
+
let offset = Math.min(this.getCurrentOffset(), text.length - 1);
|
|
399
|
+
|
|
400
|
+
if (open === close) {
|
|
401
|
+
const startOfLine = lineStart(text, offset);
|
|
402
|
+
const endOfLine = lineEnd(text, offset);
|
|
403
|
+
const pairs: Array<{ start: number; end: number }> = [];
|
|
404
|
+
let pendingQuote: number | undefined;
|
|
405
|
+
for (let i = startOfLine; i < endOfLine; i++) {
|
|
406
|
+
if (text[i] !== open || text[i - 1] === "\\") continue;
|
|
407
|
+
if (pendingQuote === undefined) pendingQuote = i;
|
|
408
|
+
else {
|
|
409
|
+
pairs.push({ start: pendingQuote, end: i });
|
|
410
|
+
pendingQuote = undefined;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (pairs.length === 0) return undefined;
|
|
414
|
+
const candidates = pairs.filter((pair) => offset >= pair.start && offset <= pair.end);
|
|
415
|
+
if (candidates.length > 0) {
|
|
416
|
+
const pair = candidates.reduce((best, candidate) =>
|
|
417
|
+
candidate.end - candidate.start < best.end - best.start ? candidate : best,
|
|
418
|
+
);
|
|
419
|
+
return around ? { start: pair.start, end: pair.end + 1 } : { start: pair.start + 1, end: pair.end };
|
|
420
|
+
}
|
|
421
|
+
let best: { start: number; end: number; distance: number } | undefined;
|
|
422
|
+
for (const pair of pairs) {
|
|
423
|
+
const innerStart = pair.start + 1;
|
|
424
|
+
const innerEnd = pair.end;
|
|
425
|
+
const distance = offset < innerStart ? innerStart - offset : offset > innerEnd ? offset - innerEnd : 0;
|
|
426
|
+
if (!best || distance < best.distance || (distance === best.distance && pair.start > best.start)) {
|
|
427
|
+
best = { start: pair.start, end: pair.end, distance };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (!best) return undefined;
|
|
431
|
+
return around ? { start: best.start, end: best.end + 1 } : { start: best.start + 1, end: best.end };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const candidates: Array<{ start: number; end: number }> = [];
|
|
435
|
+
for (let start = 0; start < text.length; start++) {
|
|
436
|
+
if (text[start] !== open) continue;
|
|
437
|
+
let depth = 0;
|
|
438
|
+
for (let end = start + 1; end < text.length; end++) {
|
|
439
|
+
if (text[end] === open) depth++;
|
|
440
|
+
else if (text[end] === close) {
|
|
441
|
+
if (depth === 0) {
|
|
442
|
+
if (offset >= start && offset <= end) candidates.push({ start, end });
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
depth--;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (candidates.length === 0) return undefined;
|
|
450
|
+
const match = candidates.reduce((best, candidate) => {
|
|
451
|
+
if (!best) return candidate;
|
|
452
|
+
return candidate.end - candidate.start < best.end - best.start ? candidate : best;
|
|
453
|
+
}, undefined as { start: number; end: number } | undefined);
|
|
454
|
+
if (!match) return undefined;
|
|
455
|
+
return around ? { start: match.start, end: match.end + 1 } : { start: match.start + 1, end: match.end };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private setMode(mode: Mode): void {
|
|
459
|
+
if (this.mode === mode) return;
|
|
460
|
+
this.mode = mode;
|
|
461
|
+
if (mode !== "visual" && mode !== "visual-line") this.visualAnchor = undefined;
|
|
462
|
+
this.emitStatus();
|
|
463
|
+
this.tui.requestRender();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private getCurrentText(): string {
|
|
467
|
+
return this.getText();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private getCurrentOffset(): number {
|
|
471
|
+
return cursorToOffset(this.getLines(), this.getCursor());
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private setCursor(line: number, col: number): void {
|
|
475
|
+
this.editor.state.cursorLine = line;
|
|
476
|
+
this.editor.setCursorCol(col);
|
|
477
|
+
this.tui.requestRender();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private moveToOffset(offset: number): void {
|
|
481
|
+
const cursor = offsetToCursor(this.getLines(), offset);
|
|
482
|
+
this.setCursor(cursor.line, cursor.col);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private enterVisual(linewise: boolean): void {
|
|
486
|
+
this.clearPending();
|
|
487
|
+
this.visualAnchor = this.getCurrentOffset();
|
|
488
|
+
this.setMode(linewise ? "visual-line" : "visual");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private exitVisual(): void {
|
|
492
|
+
const range = this.getVisualRange();
|
|
493
|
+
this.setMode("normal");
|
|
494
|
+
if (range) this.moveToOffset(range.start);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private applyVisual(action: "delete" | "change" | "yank" | "put"): void {
|
|
498
|
+
const range = this.getVisualRange();
|
|
499
|
+
if (!range) {
|
|
500
|
+
this.setMode("normal");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const text = this.getCurrentText();
|
|
504
|
+
const selected = text.slice(range.start, range.end);
|
|
505
|
+
if (action === "yank") {
|
|
506
|
+
this.writeRegister(selected, range.linewise ? "line" : "char");
|
|
507
|
+
this.setMode("normal");
|
|
508
|
+
this.moveToOffset(range.start);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (action === "put") {
|
|
512
|
+
if (!this.unnamedRegister) {
|
|
513
|
+
this.setMode("normal");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
this.edit(() => ({
|
|
517
|
+
text: replaceRange(text, range.start, range.end, this.unnamedRegister),
|
|
518
|
+
cursorOffset: Math.max(range.start, range.start + this.unnamedRegister.length - 1),
|
|
519
|
+
}));
|
|
520
|
+
this.setMode("normal");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
this.writeRegister(selected, range.linewise ? "line" : "char");
|
|
524
|
+
this.edit(() => ({
|
|
525
|
+
text: replaceRange(text, range.start, range.end),
|
|
526
|
+
cursorOffset: range.start,
|
|
527
|
+
}));
|
|
528
|
+
this.setMode(action === "change" ? "insert" : "normal");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private normalizeCursorForNormalMode(): void {
|
|
532
|
+
const cursor = this.getCursor();
|
|
533
|
+
const line = this.getLines()[cursor.line] ?? "";
|
|
534
|
+
if (line.length === 0) {
|
|
535
|
+
this.setCursor(cursor.line, 0);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (cursor.col > 0) {
|
|
539
|
+
this.setCursor(cursor.line, Math.min(cursor.col - 1, line.length - 1));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
this.setCursor(cursor.line, 0);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private edit(transform: (text: string, offset: number) => { text: string; cursorOffset: number } | undefined): boolean {
|
|
546
|
+
const text = this.getCurrentText();
|
|
547
|
+
const offset = this.getCurrentOffset();
|
|
548
|
+
const next = transform(text, offset);
|
|
549
|
+
if (!next) return false;
|
|
550
|
+
if (next.text === text && next.cursorOffset === offset) return false;
|
|
551
|
+
|
|
552
|
+
this.clearRedoStack();
|
|
553
|
+
this.setText(next.text);
|
|
554
|
+
const cursor = offsetToCursor(this.getLines(), next.cursorOffset);
|
|
555
|
+
this.setCursor(cursor.line, cursor.col);
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private enterInsert(): void {
|
|
560
|
+
this.clearPending();
|
|
561
|
+
this.setMode("insert");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private enterReplace(): void {
|
|
565
|
+
this.clearPending();
|
|
566
|
+
this.setMode("replace");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private appendAfterCursor(): void {
|
|
570
|
+
const text = this.getCurrentText();
|
|
571
|
+
const offset = this.getCurrentOffset();
|
|
572
|
+
this.moveToOffset(Math.min(offset + 1, lineEnd(text, offset)));
|
|
573
|
+
this.enterInsert();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private insertLineStart(): void {
|
|
577
|
+
const text = this.getCurrentText();
|
|
578
|
+
const offset = this.getCurrentOffset();
|
|
579
|
+
this.moveToOffset(firstNonWhitespace(text, offset));
|
|
580
|
+
this.enterInsert();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private appendLineEnd(): void {
|
|
584
|
+
const text = this.getCurrentText();
|
|
585
|
+
const offset = this.getCurrentOffset();
|
|
586
|
+
this.moveToOffset(lineEnd(text, offset));
|
|
587
|
+
this.enterInsert();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private openLineBelow(): void {
|
|
591
|
+
this.clearPending();
|
|
592
|
+
this.edit((text, offset) => {
|
|
593
|
+
const at = lineEnd(text, offset);
|
|
594
|
+
return {
|
|
595
|
+
text: `${text.slice(0, at)}\n${text.slice(at)}`,
|
|
596
|
+
cursorOffset: at + 1,
|
|
597
|
+
};
|
|
598
|
+
});
|
|
599
|
+
this.setMode("insert");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private openLineAbove(): void {
|
|
603
|
+
this.clearPending();
|
|
604
|
+
this.edit((text, offset) => {
|
|
605
|
+
const at = lineStart(text, offset);
|
|
606
|
+
return {
|
|
607
|
+
text: `${text.slice(0, at)}\n${text.slice(at)}`,
|
|
608
|
+
cursorOffset: at,
|
|
609
|
+
};
|
|
610
|
+
});
|
|
611
|
+
this.setMode("insert");
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private moveWord(direction: "next" | "prev" | "end", big: boolean, count = 1): void {
|
|
615
|
+
this.moveToOffset(this.wordMotionTarget(direction, big, count));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private moveLeft(count = 1): void {
|
|
619
|
+
let offset = this.getCurrentOffset();
|
|
620
|
+
const text = this.getCurrentText();
|
|
621
|
+
for (let i = 0; i < count; i++) {
|
|
622
|
+
offset = Math.max(lineStart(text, offset), prevGraphemeOffset(text, offset));
|
|
623
|
+
}
|
|
624
|
+
this.moveToOffset(offset);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private moveRight(count = 1): void {
|
|
628
|
+
let offset = this.getCurrentOffset();
|
|
629
|
+
const text = this.getCurrentText();
|
|
630
|
+
for (let i = 0; i < count; i++) {
|
|
631
|
+
offset = Math.min(lineLast(text, offset), nextGraphemeOffset(text, offset));
|
|
632
|
+
}
|
|
633
|
+
this.moveToOffset(offset);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private moveUp(count = 1): void {
|
|
637
|
+
let offset = this.getCurrentOffset();
|
|
638
|
+
for (let i = 0; i < count; i++) {
|
|
639
|
+
offset = moveUp(this.getCurrentText(), offset);
|
|
640
|
+
}
|
|
641
|
+
this.moveToOffset(offset);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private moveDown(count = 1): void {
|
|
645
|
+
let offset = this.getCurrentOffset();
|
|
646
|
+
for (let i = 0; i < count; i++) {
|
|
647
|
+
offset = moveDown(this.getCurrentText(), offset);
|
|
648
|
+
}
|
|
649
|
+
this.moveToOffset(offset);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private moveLineStart(): void {
|
|
653
|
+
this.moveToOffset(lineStart(this.getCurrentText(), this.getCurrentOffset()));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private moveLineFirstNonWhitespace(count = 1): void {
|
|
657
|
+
if (count > 1) this.moveDown(count - 1);
|
|
658
|
+
this.moveToOffset(firstNonWhitespace(this.getCurrentText(), this.getCurrentOffset()));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private moveToLine(lineNumber: number): void {
|
|
662
|
+
const lines = this.getLines();
|
|
663
|
+
const lineIndex = clamp(lineNumber - 1, 0, Math.max(0, lines.length - 1));
|
|
664
|
+
const col = Math.min(this.getCursor().col, Math.max(0, (lines[lineIndex] ?? "").length - 1));
|
|
665
|
+
this.setCursor(lineIndex, Math.max(0, col));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private moveParagraph(forward: boolean, count = 1): void {
|
|
669
|
+
let offset = this.getCurrentOffset();
|
|
670
|
+
for (let i = 0; i < count; i++) {
|
|
671
|
+
offset = forward ? paragraphForward(this.getCurrentText(), offset) : paragraphBackward(this.getCurrentText(), offset);
|
|
672
|
+
}
|
|
673
|
+
this.moveToOffset(offset);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private moveLineEnd(): void {
|
|
677
|
+
this.moveToOffset(lineLast(this.getCurrentText(), this.getCurrentOffset()));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private replaceUnderCursor(char: string, count = 1): void {
|
|
681
|
+
this.clearPending();
|
|
682
|
+
this.edit((text, offset) => {
|
|
683
|
+
if (offset >= lineEnd(text, offset)) return undefined;
|
|
684
|
+
let end = offset;
|
|
685
|
+
for (let i = 0; i < count; i++) {
|
|
686
|
+
end = nextGraphemeOffset(text, end);
|
|
687
|
+
if (end > lineEnd(text, offset)) {
|
|
688
|
+
end = lineEnd(text, offset);
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
text: replaceRange(text, offset, end, char.repeat(Math.max(1, count))),
|
|
694
|
+
cursorOffset: offset,
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private replaceModeInput(data: string): void {
|
|
700
|
+
if (data === "\r") {
|
|
701
|
+
super.handleInput(data);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (!(data.length === 1 && data.charCodeAt(0) >= 32)) {
|
|
705
|
+
super.handleInput(data);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
this.edit((text, offset) => {
|
|
709
|
+
if (offset < text.length && text[offset] !== "\n") {
|
|
710
|
+
return {
|
|
711
|
+
text: replaceRange(text, offset, nextGraphemeOffset(text, offset), data),
|
|
712
|
+
cursorOffset: offset + data.length,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
text: replaceRange(text, offset, offset, data),
|
|
717
|
+
cursorOffset: offset + data.length,
|
|
718
|
+
};
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private deleteToLineEnd(change: boolean): void {
|
|
723
|
+
this.clearPending();
|
|
724
|
+
this.edit((text, offset) => {
|
|
725
|
+
const end = lineEnd(text, offset);
|
|
726
|
+
if (offset > end) return undefined;
|
|
727
|
+
const deleteEnd = offset === end && end < text.length ? end + 1 : end;
|
|
728
|
+
if (deleteEnd <= offset) return undefined;
|
|
729
|
+
this.writeRegister(text.slice(offset, deleteEnd));
|
|
730
|
+
return {
|
|
731
|
+
text: replaceRange(text, offset, deleteEnd),
|
|
732
|
+
cursorOffset: offset,
|
|
733
|
+
};
|
|
734
|
+
});
|
|
735
|
+
if (change) this.setMode("insert");
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private substituteChar(): void {
|
|
739
|
+
this.clearPending();
|
|
740
|
+
this.edit((text, offset) => {
|
|
741
|
+
if (offset >= lineEnd(text, offset)) return undefined;
|
|
742
|
+
const end = nextGraphemeOffset(text, offset);
|
|
743
|
+
this.writeRegister(text.slice(offset, end));
|
|
744
|
+
return {
|
|
745
|
+
text: replaceRange(text, offset, end),
|
|
746
|
+
cursorOffset: offset,
|
|
747
|
+
};
|
|
748
|
+
});
|
|
749
|
+
this.setMode("insert");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private toggleCase(count = 1): void {
|
|
753
|
+
this.clearPending();
|
|
754
|
+
this.edit((text, offset) => {
|
|
755
|
+
if (offset >= lineEnd(text, offset)) return undefined;
|
|
756
|
+
let current = offset;
|
|
757
|
+
let result = text;
|
|
758
|
+
for (let i = 0; i < count; i++) {
|
|
759
|
+
if (current >= lineEnd(result, offset)) break;
|
|
760
|
+
const end = nextGraphemeOffset(result, current);
|
|
761
|
+
const segment = result.slice(current, end);
|
|
762
|
+
const toggled = segment === segment.toUpperCase() ? segment.toLowerCase() : segment.toUpperCase();
|
|
763
|
+
result = replaceRange(result, current, end, toggled);
|
|
764
|
+
current = current + toggled.length;
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
text: result,
|
|
768
|
+
cursorOffset: Math.max(offset, current - 1),
|
|
769
|
+
};
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
private deleteUnderCursor(count = 1): void {
|
|
774
|
+
this.clearPending();
|
|
775
|
+
this.edit((text, offset) => {
|
|
776
|
+
if (offset >= lineEnd(text, offset)) return undefined;
|
|
777
|
+
let end = offset;
|
|
778
|
+
for (let i = 0; i < count; i++) {
|
|
779
|
+
end = nextGraphemeOffset(text, end);
|
|
780
|
+
if (end > lineEnd(text, offset)) {
|
|
781
|
+
end = lineEnd(text, offset);
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
this.writeRegister(text.slice(offset, end));
|
|
786
|
+
return {
|
|
787
|
+
text: replaceRange(text, offset, end),
|
|
788
|
+
cursorOffset: offset,
|
|
789
|
+
};
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private applyRange(start: number, end: number, change = false, yank = false): void {
|
|
794
|
+
this.clearPending();
|
|
795
|
+
const from = Math.min(start, end);
|
|
796
|
+
const to = Math.max(start, end);
|
|
797
|
+
if (to <= from) return;
|
|
798
|
+
const text = this.getCurrentText();
|
|
799
|
+
this.writeRegister(text.slice(from, to), "char");
|
|
800
|
+
if (yank) {
|
|
801
|
+
this.flashSelection(from, to, false);
|
|
802
|
+
this.moveToOffset(start <= end ? from : Math.max(from, to - 1));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
this.edit(() => ({
|
|
806
|
+
text: replaceRange(text, from, to),
|
|
807
|
+
cursorOffset: from,
|
|
808
|
+
}));
|
|
809
|
+
if (change) this.setMode("insert");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private wordMotionTarget(direction: "next" | "prev" | "end", big: boolean, count: number, from = this.getCurrentOffset()): number {
|
|
813
|
+
let target = from;
|
|
814
|
+
for (let i = 0; i < count; i++) {
|
|
815
|
+
const text = this.getCurrentText();
|
|
816
|
+
target =
|
|
817
|
+
direction === "next"
|
|
818
|
+
? nextWordStart(text, target, big)
|
|
819
|
+
: direction === "prev"
|
|
820
|
+
? prevWordStart(text, target, big)
|
|
821
|
+
: wordEnd(text, target, big);
|
|
822
|
+
}
|
|
823
|
+
return target;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private deleteWord(change: boolean): void {
|
|
827
|
+
const offset = this.getCurrentOffset();
|
|
828
|
+
const endOffset = this.wordMotionTarget("next", false, this.takeCount(1), offset);
|
|
829
|
+
this.applyRange(offset, endOffset, change);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private deleteLine(count = 1, direction: -1 | 1 | 0 = 0): void {
|
|
833
|
+
this.clearPending();
|
|
834
|
+
this.edit((text, offset) => {
|
|
835
|
+
if (text.length === 0) return undefined;
|
|
836
|
+
const { start, end } = this.lineBlockRange(count, direction);
|
|
837
|
+
this.writeRegister(text.slice(start, end), "line");
|
|
838
|
+
const nextText = replaceRange(text, start, end);
|
|
839
|
+
return {
|
|
840
|
+
text: nextText,
|
|
841
|
+
cursorOffset: Math.min(start, nextText.length),
|
|
842
|
+
};
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private substituteLine(count = 1): void {
|
|
847
|
+
this.deleteLine(count);
|
|
848
|
+
this.setMode("insert");
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private lineBlockRange(count: number, direction: -1 | 1 | 0): { start: number; end: number } {
|
|
852
|
+
const text = this.getCurrentText();
|
|
853
|
+
const offset = this.getCurrentOffset();
|
|
854
|
+
let start = lineStart(text, offset);
|
|
855
|
+
let end = lineEnd(text, offset);
|
|
856
|
+
if (direction >= 0) {
|
|
857
|
+
for (let i = 1; i < count; i++) {
|
|
858
|
+
if (end >= text.length) break;
|
|
859
|
+
end = lineEnd(text, end + 1);
|
|
860
|
+
}
|
|
861
|
+
end = Math.min(end + 1, text.length);
|
|
862
|
+
} else {
|
|
863
|
+
for (let i = 1; i < count; i++) {
|
|
864
|
+
if (start === 0) break;
|
|
865
|
+
start = lineStart(text, start - 1);
|
|
866
|
+
}
|
|
867
|
+
end = Math.min(lineEnd(text, offset) + 1, text.length);
|
|
868
|
+
}
|
|
869
|
+
return { start, end };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private yankLine(count = 1): void {
|
|
873
|
+
this.clearPending();
|
|
874
|
+
const { start, end } = this.lineBlockRange(count, 0);
|
|
875
|
+
this.writeRegister(this.getCurrentText().slice(start, end), "line");
|
|
876
|
+
this.flashSelection(start, end, true);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private put(after: boolean, count = 1): void {
|
|
880
|
+
this.clearPending();
|
|
881
|
+
if (!this.unnamedRegister) return;
|
|
882
|
+
const register = this.unnamedRegister.repeat(Math.max(1, count));
|
|
883
|
+
const linewise = this.unnamedRegisterType === "line";
|
|
884
|
+
this.edit((text, offset) => {
|
|
885
|
+
if (linewise) {
|
|
886
|
+
const currentLineEnd = lineEnd(text, offset);
|
|
887
|
+
const insertAt = after ? currentLineEnd + (currentLineEnd < text.length ? 1 : 0) : lineStart(text, offset);
|
|
888
|
+
const needsLeadingNewline = after && insertAt === text.length && text.length > 0 && text[text.length - 1] !== "\n";
|
|
889
|
+
const insertion = needsLeadingNewline ? `\n${register}` : register;
|
|
890
|
+
const nextText = replaceRange(text, insertAt, insertAt, insertion);
|
|
891
|
+
return { text: nextText, cursorOffset: insertAt + (needsLeadingNewline ? 1 : 0) };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const insertAt = after ? Math.min(nextGraphemeOffset(text, offset), text.length) : offset;
|
|
895
|
+
const nextText = replaceRange(text, insertAt, insertAt, register);
|
|
896
|
+
return {
|
|
897
|
+
text: nextText,
|
|
898
|
+
cursorOffset: Math.max(insertAt, insertAt + register.length - 1),
|
|
899
|
+
};
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private joinLines(): void {
|
|
904
|
+
this.clearPending();
|
|
905
|
+
this.edit((text, offset) => {
|
|
906
|
+
const end = lineEnd(text, offset);
|
|
907
|
+
if (end >= text.length) return undefined;
|
|
908
|
+
|
|
909
|
+
let next = end + 1;
|
|
910
|
+
while (next < text.length && (text[next] === " " || text[next] === "\t")) next++;
|
|
911
|
+
|
|
912
|
+
const trailing = end > 0 && /[ \t]/.test(text[end - 1] ?? "");
|
|
913
|
+
const paren = next < text.length && text[next] === ")";
|
|
914
|
+
let nextText = replaceRange(text, end, next);
|
|
915
|
+
if (!trailing && !paren) {
|
|
916
|
+
nextText = replaceRange(nextText, end, end, " ");
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return {
|
|
920
|
+
text: nextText,
|
|
921
|
+
cursorOffset: end,
|
|
922
|
+
};
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private repeatFind(reverse = false): void {
|
|
927
|
+
if (!this.lastFind) return;
|
|
928
|
+
this.findChar(this.lastFind.char, reverse ? !this.lastFind.forward : this.lastFind.forward, this.lastFind.till);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private findCharTarget(char: string, forward: boolean, till = false): number | undefined {
|
|
932
|
+
const text = this.getCurrentText();
|
|
933
|
+
const offset = this.getCurrentOffset();
|
|
934
|
+
const start = lineStart(text, offset);
|
|
935
|
+
const end = lineEnd(text, offset);
|
|
936
|
+
|
|
937
|
+
if (forward) {
|
|
938
|
+
for (let i = offset + 1; i < end; i++) {
|
|
939
|
+
if (text[i] === char) return till ? i - 1 : i;
|
|
940
|
+
}
|
|
941
|
+
return undefined;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
for (let i = offset - 1; i >= start; i--) {
|
|
945
|
+
if (text[i] === char) return till ? i + 1 : i;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
private findChar(char: string, forward: boolean, till = false): void {
|
|
950
|
+
this.lastFind = { char, forward, till };
|
|
951
|
+
const target = this.findCharTarget(char, forward, till);
|
|
952
|
+
if (target !== undefined) this.moveToOffset(target);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private isInterruptKey(data: string): boolean {
|
|
956
|
+
return (this as unknown as { keybindings: { matches(data: string, action: string): boolean } }).keybindings.matches(
|
|
957
|
+
data,
|
|
958
|
+
"app.interrupt",
|
|
959
|
+
) || matchesKey(data, "escape") || matchesKey(data, "ctrl+[");
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private performUndo(): void {
|
|
963
|
+
const before = this.captureSnapshot();
|
|
964
|
+
super.handleInput("\x1f");
|
|
965
|
+
const after = this.captureSnapshot();
|
|
966
|
+
if (after.text !== before.text || after.cursor.line !== before.cursor.line || after.cursor.col !== before.cursor.col) {
|
|
967
|
+
this.redoStack.push(before);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private performRedo(): void {
|
|
972
|
+
const snapshot = this.redoStack.pop();
|
|
973
|
+
if (!snapshot) return;
|
|
974
|
+
this.restoreSnapshot(snapshot);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
override render(width: number): string[] {
|
|
978
|
+
const range = this.getVisualRange() ?? this.flashRange;
|
|
979
|
+
if (!range) {
|
|
980
|
+
const lines = super.render(width);
|
|
981
|
+
if (this.mode !== "insert") return lines;
|
|
982
|
+
return lines.map((line) =>
|
|
983
|
+
line
|
|
984
|
+
.replace(/\x1b_pi:c\x07\x1b\[7m \x1b\[0m/g, "\x1b_pi:c\x07")
|
|
985
|
+
.replace(/\x1b_pi:c\x07\x1b\[7m([\s\S])\x1b\[0m/g, "\x1b_pi:c\x07$1"),
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const editor = this.editor;
|
|
990
|
+
const paddingX = Math.min(editor.paddingX ?? 0, Math.max(0, Math.floor((width - 1) / 2)));
|
|
991
|
+
const contentWidth = Math.max(1, width - paddingX * 2);
|
|
992
|
+
const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
|
|
993
|
+
const layout = editor.layoutText?.(layoutWidth);
|
|
994
|
+
if (!layout) return super.render(width);
|
|
995
|
+
|
|
996
|
+
const terminalRows = this.tui.terminal.rows;
|
|
997
|
+
const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));
|
|
998
|
+
const selectedBg = (s: string) => `\x1b[7m${s}\x1b[0m`;
|
|
999
|
+
const horizontal = (editor.borderColor ?? ((s: string) => s))("─");
|
|
1000
|
+
const leftPadding = " ".repeat(paddingX);
|
|
1001
|
+
const rightPadding = leftPadding;
|
|
1002
|
+
const text = this.getCurrentText();
|
|
1003
|
+
|
|
1004
|
+
let searchFrom = 0;
|
|
1005
|
+
const mapped = layout.map((line) => {
|
|
1006
|
+
const plain = line.text;
|
|
1007
|
+
if (plain.length === 0) {
|
|
1008
|
+
const start = Math.min(searchFrom, text.length);
|
|
1009
|
+
if (text[start] === "\n") searchFrom = start + 1;
|
|
1010
|
+
return { ...line, absStart: start, absEnd: start };
|
|
1011
|
+
}
|
|
1012
|
+
let start = text.indexOf(plain, searchFrom);
|
|
1013
|
+
if (start === -1 && searchFrom > 0) start = text.indexOf(plain);
|
|
1014
|
+
if (start !== -1) {
|
|
1015
|
+
searchFrom = start + plain.length;
|
|
1016
|
+
if (text[searchFrom] === "\n") searchFrom += 1;
|
|
1017
|
+
}
|
|
1018
|
+
return { ...line, absStart: start, absEnd: start === -1 ? -1 : start + plain.length };
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
let cursorLineIndex = mapped.findIndex((line) => line.hasCursor);
|
|
1022
|
+
if (cursorLineIndex === -1) cursorLineIndex = 0;
|
|
1023
|
+
editor.scrollOffset = editor.scrollOffset ?? 0;
|
|
1024
|
+
if (cursorLineIndex < editor.scrollOffset) editor.scrollOffset = cursorLineIndex;
|
|
1025
|
+
else if (cursorLineIndex >= editor.scrollOffset + maxVisibleLines) editor.scrollOffset = cursorLineIndex - maxVisibleLines + 1;
|
|
1026
|
+
const maxScrollOffset = Math.max(0, mapped.length - maxVisibleLines);
|
|
1027
|
+
editor.scrollOffset = Math.max(0, Math.min(editor.scrollOffset, maxScrollOffset));
|
|
1028
|
+
const visibleLines = mapped.slice(editor.scrollOffset, editor.scrollOffset + maxVisibleLines);
|
|
1029
|
+
|
|
1030
|
+
const result: string[] = [];
|
|
1031
|
+
if (editor.scrollOffset > 0) {
|
|
1032
|
+
const indicator = `─── ↑ ${editor.scrollOffset} more `;
|
|
1033
|
+
result.push(truncateToWidth((editor.borderColor ?? ((s: string) => s))(indicator + "─".repeat(Math.max(0, width - visibleWidth(indicator)))), width, ""));
|
|
1034
|
+
} else {
|
|
1035
|
+
result.push(truncateToWidth(horizontal.repeat(width), width, ""));
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
for (const line of visibleLines) {
|
|
1039
|
+
let displayText = line.text;
|
|
1040
|
+
if (line.absStart !== -1) {
|
|
1041
|
+
const lineRangeEnd = range.linewise ? Math.max(line.absEnd, line.absStart + line.text.length, line.absEnd + 1) : line.absEnd;
|
|
1042
|
+
const overlapStart = Math.max(range.start, line.absStart);
|
|
1043
|
+
const overlapEnd = Math.min(range.end, lineRangeEnd);
|
|
1044
|
+
if (range.linewise && overlapStart <= line.absStart && overlapEnd > line.absStart) {
|
|
1045
|
+
displayText = selectedBg(displayText || " ");
|
|
1046
|
+
} else if (overlapEnd > overlapStart) {
|
|
1047
|
+
const localStart = overlapStart - line.absStart;
|
|
1048
|
+
const localEnd = overlapEnd - line.absStart;
|
|
1049
|
+
displayText = `${displayText.slice(0, localStart)}${selectedBg(displayText.slice(localStart, localEnd) || " ")}${displayText.slice(localEnd)}`;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
let lineVisibleWidth = visibleWidth(line.text);
|
|
1053
|
+
if (!this.flashRange && (this.mode !== "visual" && this.mode !== "visual-line") && line.hasCursor && line.cursorPos !== undefined) {
|
|
1054
|
+
const before = displayText.slice(0, line.cursorPos);
|
|
1055
|
+
const after = displayText.slice(line.cursorPos);
|
|
1056
|
+
if (after.length > 0) {
|
|
1057
|
+
const first = [...graphemeSegmenter.segment(after)][0]?.segment || "";
|
|
1058
|
+
displayText = before + `\x1b[7m${first}\x1b[0m` + after.slice(first.length);
|
|
1059
|
+
} else {
|
|
1060
|
+
displayText = before + "\x1b[7m \x1b[0m";
|
|
1061
|
+
lineVisibleWidth += 1;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));
|
|
1065
|
+
result.push(truncateToWidth(`${leftPadding}${displayText}${padding}${rightPadding}`, width, ""));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const linesBelow = mapped.length - (editor.scrollOffset + visibleLines.length);
|
|
1069
|
+
if (linesBelow > 0) {
|
|
1070
|
+
const indicator = `─── ↓ ${linesBelow} more `;
|
|
1071
|
+
result.push(truncateToWidth((editor.borderColor ?? ((s: string) => s))(indicator + "─".repeat(Math.max(0, width - visibleWidth(indicator)))), width, ""));
|
|
1072
|
+
} else {
|
|
1073
|
+
result.push(truncateToWidth(horizontal.repeat(width), width, ""));
|
|
1074
|
+
}
|
|
1075
|
+
return result;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
private applyPendingOperator(target: number, inclusive = false): boolean {
|
|
1079
|
+
const offset = this.getCurrentOffset();
|
|
1080
|
+
const change = this.pending === "c";
|
|
1081
|
+
const yank = this.pending === "y";
|
|
1082
|
+
this.applyRange(offset, inclusive ? target + 1 : target, change, yank);
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
private handlePendingFindOp(data: string): boolean {
|
|
1087
|
+
if (!this.pendingFindOp || !(data.length === 1 && data.charCodeAt(0) >= 32)) return false;
|
|
1088
|
+
let target: number | undefined;
|
|
1089
|
+
for (let i = 0; i < this.pendingFindOp.count; i++) {
|
|
1090
|
+
const next = this.findCharTarget(data, this.pendingFindOp.motion === "f" || this.pendingFindOp.motion === "t", this.pendingFindOp.motion === "t" || this.pendingFindOp.motion === "T");
|
|
1091
|
+
if (next === undefined) {
|
|
1092
|
+
target = undefined;
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
target = next;
|
|
1096
|
+
this.moveToOffset(next);
|
|
1097
|
+
}
|
|
1098
|
+
if (target === undefined) {
|
|
1099
|
+
this.clearPending();
|
|
1100
|
+
return true;
|
|
1101
|
+
}
|
|
1102
|
+
this.pending = this.pendingFindOp.op;
|
|
1103
|
+
const inclusive = this.pendingFindOp.motion === "f" || this.pendingFindOp.motion === "F";
|
|
1104
|
+
this.pendingFindOp = undefined;
|
|
1105
|
+
return this.applyPendingOperator(target, inclusive);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
private handleTextObject(data: string): boolean {
|
|
1109
|
+
if (!this.pendingTextObject || !this.pending) {
|
|
1110
|
+
this.clearPending();
|
|
1111
|
+
return false;
|
|
1112
|
+
}
|
|
1113
|
+
const around = this.pendingTextObject === "a";
|
|
1114
|
+
const normalized = data === ")" ? "(" : data === "]" ? "[" : data === "}" ? "{" : data;
|
|
1115
|
+
const range = normalized === "w" ? this.wordObjectRange(around) : this.delimitedObjectRange(normalized, around);
|
|
1116
|
+
if (!range) {
|
|
1117
|
+
this.clearPending();
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
const change = this.pending === "c";
|
|
1121
|
+
const yank = this.pending === "y";
|
|
1122
|
+
this.applyRange(range.start, range.end, change, yank);
|
|
1123
|
+
return true;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
private handlePending(data: string): boolean {
|
|
1127
|
+
if (this.isInterruptKey(data)) {
|
|
1128
|
+
this.clearPending();
|
|
1129
|
+
this.tui.requestRender();
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
switch (this.pending) {
|
|
1134
|
+
case "r": {
|
|
1135
|
+
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
1136
|
+
this.replaceUnderCursor(data, this.takeCount(1));
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1141
|
+
case "y":
|
|
1142
|
+
case "d":
|
|
1143
|
+
case "c": {
|
|
1144
|
+
if (data === "i" || data === "a") {
|
|
1145
|
+
this.pendingTextObject = data;
|
|
1146
|
+
this.emitStatus();
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
if (data === "f" || data === "F" || data === "t" || data === "T") {
|
|
1150
|
+
this.pendingFindOp = { op: this.pending, motion: data, count: this.takeCount(1) };
|
|
1151
|
+
this.emitStatus();
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
if (data === this.pending) {
|
|
1155
|
+
const count = this.takeCount(1);
|
|
1156
|
+
if (this.pending === "y") this.yankLine(count);
|
|
1157
|
+
else if (this.pending === "d") this.deleteLine(count);
|
|
1158
|
+
else this.substituteLine(count);
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
1161
|
+
if (data === "w") return this.applyPendingOperator(this.wordMotionTarget("next", false, this.takeCount(1)));
|
|
1162
|
+
if (data === "e") return this.applyPendingOperator(this.wordMotionTarget("end", false, this.takeCount(1)), true);
|
|
1163
|
+
if (data === "b") return this.applyPendingOperator(this.wordMotionTarget("prev", false, this.takeCount(1)));
|
|
1164
|
+
if (data === "W") return this.applyPendingOperator(this.wordMotionTarget("next", true, this.takeCount(1)));
|
|
1165
|
+
if (data === "E") return this.applyPendingOperator(this.wordMotionTarget("end", true, this.takeCount(1)), true);
|
|
1166
|
+
if (data === "B") return this.applyPendingOperator(this.wordMotionTarget("prev", true, this.takeCount(1)));
|
|
1167
|
+
if (data === "$") return this.applyPendingOperator(lineEnd(this.getCurrentText(), this.getCurrentOffset()));
|
|
1168
|
+
if (data === "0") return this.applyPendingOperator(lineStart(this.getCurrentText(), this.getCurrentOffset()));
|
|
1169
|
+
if (data === "^") return this.applyPendingOperator(firstNonWhitespace(this.getCurrentText(), this.getCurrentOffset()));
|
|
1170
|
+
if (data === "_") {
|
|
1171
|
+
const count = this.takeCount(1);
|
|
1172
|
+
if (this.pending === "y") this.yankLine(count);
|
|
1173
|
+
else if (this.pending === "d") this.deleteLine(count);
|
|
1174
|
+
else this.substituteLine(count);
|
|
1175
|
+
return true;
|
|
1176
|
+
}
|
|
1177
|
+
if (data === "G") {
|
|
1178
|
+
const offset = this.getCurrentOffset();
|
|
1179
|
+
const start = lineStart(this.getCurrentText(), offset);
|
|
1180
|
+
const end = this.getCurrentText().length;
|
|
1181
|
+
this.clearPending();
|
|
1182
|
+
this.writeRegister(this.getCurrentText().slice(start, end), "line");
|
|
1183
|
+
if (this.pending === "y") return true;
|
|
1184
|
+
this.edit(() => ({ text: replaceRange(this.getCurrentText(), start, end), cursorOffset: start }));
|
|
1185
|
+
if (this.pending === "c") this.setMode("insert");
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
if (data === "j") {
|
|
1189
|
+
const count = this.takeCount(1);
|
|
1190
|
+
if (this.pending === "y") this.yankLine(count + 1);
|
|
1191
|
+
else if (this.pending === "d") this.deleteLine(count + 1, 1);
|
|
1192
|
+
else this.substituteLine(count + 1);
|
|
1193
|
+
return true;
|
|
1194
|
+
}
|
|
1195
|
+
if (data === "k") {
|
|
1196
|
+
const count = this.takeCount(1);
|
|
1197
|
+
if (this.pending === "y") {
|
|
1198
|
+
const { start, end } = this.lineBlockRange(count + 1, -1);
|
|
1199
|
+
this.writeRegister(this.getCurrentText().slice(start, end), "line");
|
|
1200
|
+
this.clearPending();
|
|
1201
|
+
} else if (this.pending === "d") this.deleteLine(count + 1, -1);
|
|
1202
|
+
else this.deleteLine(count + 1, -1), this.setMode("insert");
|
|
1203
|
+
return true;
|
|
1204
|
+
}
|
|
1205
|
+
break;
|
|
1206
|
+
}
|
|
1207
|
+
case "f":
|
|
1208
|
+
case "F":
|
|
1209
|
+
case "t":
|
|
1210
|
+
case "T": {
|
|
1211
|
+
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
1212
|
+
const forward = this.pending === "f" || this.pending === "t";
|
|
1213
|
+
const till = this.pending === "t" || this.pending === "T";
|
|
1214
|
+
if (this.pendingTextObject) {
|
|
1215
|
+
this.clearPending();
|
|
1216
|
+
return true;
|
|
1217
|
+
}
|
|
1218
|
+
if (this.pending === "f" || this.pending === "F" || this.pending === "t" || this.pending === "T") {
|
|
1219
|
+
this.findChar(data, forward, till);
|
|
1220
|
+
this.clearPending();
|
|
1221
|
+
return true;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
break;
|
|
1225
|
+
}
|
|
1226
|
+
default:
|
|
1227
|
+
return false;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const printable = data.length === 1 && data.charCodeAt(0) >= 32;
|
|
1231
|
+
this.clearPending();
|
|
1232
|
+
return printable;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
handleInput(data: string): void {
|
|
1236
|
+
if (this.isInterruptKey(data)) {
|
|
1237
|
+
if (this.mode === "visual" || this.mode === "visual-line") {
|
|
1238
|
+
this.exitVisual();
|
|
1239
|
+
} else if (this.mode === "insert" || this.mode === "replace") {
|
|
1240
|
+
if (this.isShowingAutocomplete()) {
|
|
1241
|
+
super.handleInput(data);
|
|
1242
|
+
}
|
|
1243
|
+
this.clearPending();
|
|
1244
|
+
this.normalizeCursorForNormalMode();
|
|
1245
|
+
this.setMode("normal");
|
|
1246
|
+
} else if (this.pending || this.pendingTextObject || this.pendingFindOp || this.pendingG || this.count) {
|
|
1247
|
+
this.clearPending();
|
|
1248
|
+
this.tui.requestRender();
|
|
1249
|
+
} else {
|
|
1250
|
+
super.handleInput(data);
|
|
1251
|
+
}
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (this.mode === "insert" || this.mode === "replace") {
|
|
1256
|
+
if (this.mode === "insert" && (matchesKey(data, Key.shiftAlt("a")) || data === "\x1bA")) {
|
|
1257
|
+
this.moveToOffset(lineEnd(this.getCurrentText(), this.getCurrentOffset()));
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (this.mode === "insert" && (matchesKey(data, Key.shiftAlt("i")) || data === "\x1bI")) {
|
|
1261
|
+
this.moveToOffset(lineStart(this.getCurrentText(), this.getCurrentOffset()));
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
if (this.mode === "insert" && (matchesKey(data, Key.alt("o")) || data === "\x1bo")) {
|
|
1265
|
+
this.openLineBelow();
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (this.mode === "insert" && (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO")) {
|
|
1269
|
+
this.openLineAbove();
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
if (this.mode === "replace") {
|
|
1273
|
+
this.replaceModeInput(data);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
super.handleInput(data);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if (this.mode === "visual" || this.mode === "visual-line") {
|
|
1281
|
+
switch (data) {
|
|
1282
|
+
case "v":
|
|
1283
|
+
if (this.mode === "visual") this.exitVisual();
|
|
1284
|
+
else this.enterVisual(false);
|
|
1285
|
+
return;
|
|
1286
|
+
case "V":
|
|
1287
|
+
if (this.mode === "visual-line") this.exitVisual();
|
|
1288
|
+
else this.enterVisual(true);
|
|
1289
|
+
return;
|
|
1290
|
+
case "d":
|
|
1291
|
+
case "x":
|
|
1292
|
+
this.applyVisual("delete");
|
|
1293
|
+
return;
|
|
1294
|
+
case "c":
|
|
1295
|
+
this.applyVisual("change");
|
|
1296
|
+
return;
|
|
1297
|
+
case "y":
|
|
1298
|
+
this.applyVisual("yank");
|
|
1299
|
+
return;
|
|
1300
|
+
case "p":
|
|
1301
|
+
case "P":
|
|
1302
|
+
this.applyVisual("put");
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
if (this.pending && data >= "0" && data <= "9") {
|
|
1308
|
+
if (this.count.length < 5) this.count += data;
|
|
1309
|
+
if (this.count.length >= 5) {
|
|
1310
|
+
this.clearPending();
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
this.emitStatus();
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if ((this.pending === "d" || this.pending === "c" || this.pending === "y") && (data === "i" || data === "a")) {
|
|
1318
|
+
this.pendingTextObject = data;
|
|
1319
|
+
this.emitStatus();
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (this.pendingTextObject && (data === '"' || data === "'" || data === "`" || data === "(" || data === ")" || data === "[" || data === "]" || data === "{" || data === "}" || data === "w")) {
|
|
1323
|
+
this.handleTextObject(data);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
if (this.pendingFindOp) {
|
|
1329
|
+
if (this.handlePendingFindOp(data)) return;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (this.pendingTextObject) {
|
|
1333
|
+
if (this.handleTextObject(data)) return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (this.pending && this.handlePending(data)) {
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (this.pendingG) {
|
|
1341
|
+
if (data === "g") {
|
|
1342
|
+
const count = this.takeCount(1);
|
|
1343
|
+
this.pendingG = false;
|
|
1344
|
+
this.emitStatus();
|
|
1345
|
+
this.moveToLine(count);
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
this.pendingG = false;
|
|
1349
|
+
this.emitStatus();
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (data >= "0" && data <= "9" && (data !== "0" || this.count.length > 0)) {
|
|
1353
|
+
if (this.count.length < 5) this.count += data;
|
|
1354
|
+
if (this.count.length >= 5) {
|
|
1355
|
+
this.clearPending();
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
this.emitStatus();
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
switch (data) {
|
|
1363
|
+
case "v":
|
|
1364
|
+
this.enterVisual(false);
|
|
1365
|
+
return;
|
|
1366
|
+
case "V":
|
|
1367
|
+
this.enterVisual(true);
|
|
1368
|
+
return;
|
|
1369
|
+
case "u":
|
|
1370
|
+
case "\x1f": {
|
|
1371
|
+
const count = this.takeCount(1);
|
|
1372
|
+
for (let i = 0; i < count; i++) this.performUndo();
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
case "\x12": {
|
|
1376
|
+
const count = this.takeCount(1);
|
|
1377
|
+
for (let i = 0; i < count; i++) this.performRedo();
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
case "I":
|
|
1381
|
+
this.insertLineStart();
|
|
1382
|
+
return;
|
|
1383
|
+
case "A":
|
|
1384
|
+
this.appendLineEnd();
|
|
1385
|
+
return;
|
|
1386
|
+
case "h":
|
|
1387
|
+
this.moveLeft(this.takeCount(1));
|
|
1388
|
+
return;
|
|
1389
|
+
case "j":
|
|
1390
|
+
this.moveDown(this.takeCount(1));
|
|
1391
|
+
return;
|
|
1392
|
+
case "k":
|
|
1393
|
+
this.moveUp(this.takeCount(1));
|
|
1394
|
+
return;
|
|
1395
|
+
case "l":
|
|
1396
|
+
this.moveRight(this.takeCount(1));
|
|
1397
|
+
return;
|
|
1398
|
+
case "w":
|
|
1399
|
+
this.moveWord("next", false, this.takeCount(1));
|
|
1400
|
+
return;
|
|
1401
|
+
case "b":
|
|
1402
|
+
this.moveWord("prev", false, this.takeCount(1));
|
|
1403
|
+
return;
|
|
1404
|
+
case "e":
|
|
1405
|
+
this.moveWord("end", false, this.takeCount(1));
|
|
1406
|
+
return;
|
|
1407
|
+
case "W":
|
|
1408
|
+
this.moveWord("next", true, this.takeCount(1));
|
|
1409
|
+
return;
|
|
1410
|
+
case "B":
|
|
1411
|
+
this.moveWord("prev", true, this.takeCount(1));
|
|
1412
|
+
return;
|
|
1413
|
+
case "E":
|
|
1414
|
+
this.moveWord("end", true, this.takeCount(1));
|
|
1415
|
+
return;
|
|
1416
|
+
case "0":
|
|
1417
|
+
this.moveLineStart();
|
|
1418
|
+
this.count = "";
|
|
1419
|
+
return;
|
|
1420
|
+
case "^":
|
|
1421
|
+
this.moveLineFirstNonWhitespace();
|
|
1422
|
+
this.count = "";
|
|
1423
|
+
return;
|
|
1424
|
+
case "_":
|
|
1425
|
+
this.moveLineFirstNonWhitespace(this.takeCount(1));
|
|
1426
|
+
return;
|
|
1427
|
+
case "$":
|
|
1428
|
+
this.moveLineEnd();
|
|
1429
|
+
this.count = "";
|
|
1430
|
+
return;
|
|
1431
|
+
case "g":
|
|
1432
|
+
this.pendingG = true;
|
|
1433
|
+
this.emitStatus();
|
|
1434
|
+
return;
|
|
1435
|
+
case "G":
|
|
1436
|
+
this.moveToLine(this.takeCount(this.getLines().length));
|
|
1437
|
+
return;
|
|
1438
|
+
case "{":
|
|
1439
|
+
this.moveParagraph(false, this.takeCount(1));
|
|
1440
|
+
return;
|
|
1441
|
+
case "}":
|
|
1442
|
+
this.moveParagraph(true, this.takeCount(1));
|
|
1443
|
+
return;
|
|
1444
|
+
case "o":
|
|
1445
|
+
this.openLineBelow();
|
|
1446
|
+
return;
|
|
1447
|
+
case "O":
|
|
1448
|
+
this.openLineAbove();
|
|
1449
|
+
return;
|
|
1450
|
+
case "x":
|
|
1451
|
+
this.deleteUnderCursor(this.takeCount(1));
|
|
1452
|
+
return;
|
|
1453
|
+
case "~":
|
|
1454
|
+
this.toggleCase(this.takeCount(1));
|
|
1455
|
+
return;
|
|
1456
|
+
case "s":
|
|
1457
|
+
this.substituteChar();
|
|
1458
|
+
return;
|
|
1459
|
+
case "D":
|
|
1460
|
+
this.deleteToLineEnd(false);
|
|
1461
|
+
return;
|
|
1462
|
+
case "C":
|
|
1463
|
+
this.deleteToLineEnd(true);
|
|
1464
|
+
return;
|
|
1465
|
+
case "r":
|
|
1466
|
+
this.pending = "r";
|
|
1467
|
+
return;
|
|
1468
|
+
case "R":
|
|
1469
|
+
this.enterReplace();
|
|
1470
|
+
return;
|
|
1471
|
+
case "p":
|
|
1472
|
+
this.put(true, this.takeCount(1));
|
|
1473
|
+
return;
|
|
1474
|
+
case "P":
|
|
1475
|
+
this.put(false, this.takeCount(1));
|
|
1476
|
+
return;
|
|
1477
|
+
case "Y":
|
|
1478
|
+
this.yankLine(this.takeCount(1));
|
|
1479
|
+
return;
|
|
1480
|
+
case ";":
|
|
1481
|
+
this.repeatFind(false);
|
|
1482
|
+
return;
|
|
1483
|
+
case ",":
|
|
1484
|
+
this.repeatFind(true);
|
|
1485
|
+
return;
|
|
1486
|
+
case "J":
|
|
1487
|
+
this.joinLines();
|
|
1488
|
+
return;
|
|
1489
|
+
case "S":
|
|
1490
|
+
this.substituteLine(this.takeCount(1));
|
|
1491
|
+
return;
|
|
1492
|
+
case "d":
|
|
1493
|
+
this.pending = "d";
|
|
1494
|
+
this.emitStatus();
|
|
1495
|
+
return;
|
|
1496
|
+
case "c":
|
|
1497
|
+
this.pending = "c";
|
|
1498
|
+
this.emitStatus();
|
|
1499
|
+
return;
|
|
1500
|
+
case "y":
|
|
1501
|
+
this.pending = "y";
|
|
1502
|
+
this.emitStatus();
|
|
1503
|
+
return;
|
|
1504
|
+
case "i":
|
|
1505
|
+
if (this.pending === "d" || this.pending === "c" || this.pending === "y") {
|
|
1506
|
+
this.pendingTextObject = "i";
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
this.enterInsert();
|
|
1510
|
+
return;
|
|
1511
|
+
case "a":
|
|
1512
|
+
if (this.pending === "d" || this.pending === "c" || this.pending === "y") {
|
|
1513
|
+
this.pendingTextObject = "a";
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
this.appendAfterCursor();
|
|
1517
|
+
return;
|
|
1518
|
+
case "f":
|
|
1519
|
+
this.pending = "f";
|
|
1520
|
+
this.emitStatus();
|
|
1521
|
+
return;
|
|
1522
|
+
case "F":
|
|
1523
|
+
this.pending = "F";
|
|
1524
|
+
this.emitStatus();
|
|
1525
|
+
return;
|
|
1526
|
+
case "t":
|
|
1527
|
+
this.pending = "t";
|
|
1528
|
+
this.emitStatus();
|
|
1529
|
+
return;
|
|
1530
|
+
case "T":
|
|
1531
|
+
this.pending = "T";
|
|
1532
|
+
this.emitStatus();
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if (data.length === 1 && data.charCodeAt(0) >= 32) return;
|
|
1537
|
+
if (this.count || this.pendingG || this.pendingTextObject || this.pendingFindOp || this.pending) this.clearPending();
|
|
1538
|
+
super.handleInput(data);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
export default function (pi: ExtensionAPI) {
|
|
1543
|
+
let mode: Mode = "insert";
|
|
1544
|
+
let pendingStatus = "";
|
|
1545
|
+
let enabled = true;
|
|
1546
|
+
|
|
1547
|
+
const applyVimMode = (ctx: ExtensionContext): void => {
|
|
1548
|
+
mode = "insert";
|
|
1549
|
+
pendingStatus = "";
|
|
1550
|
+
|
|
1551
|
+
if (!enabled) {
|
|
1552
|
+
ctx.ui.setFooter(undefined);
|
|
1553
|
+
ctx.ui.setEditorComponent(undefined);
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
1558
|
+
const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
|
|
1559
|
+
|
|
1560
|
+
return {
|
|
1561
|
+
dispose: unsubscribe,
|
|
1562
|
+
invalidate() {},
|
|
1563
|
+
render(width: number): string[] {
|
|
1564
|
+
let totalInput = 0;
|
|
1565
|
+
let totalOutput = 0;
|
|
1566
|
+
let totalCacheRead = 0;
|
|
1567
|
+
let totalCacheWrite = 0;
|
|
1568
|
+
let totalCost = 0;
|
|
1569
|
+
|
|
1570
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
1571
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
1572
|
+
totalInput += entry.message.usage.input;
|
|
1573
|
+
totalOutput += entry.message.usage.output;
|
|
1574
|
+
totalCacheRead += entry.message.usage.cacheRead;
|
|
1575
|
+
totalCacheWrite += entry.message.usage.cacheWrite;
|
|
1576
|
+
totalCost += entry.message.usage.cost.total;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const contextUsage = ctx.getContextUsage();
|
|
1581
|
+
const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
1582
|
+
const contextPercentValue = contextUsage?.percent ?? 0;
|
|
1583
|
+
const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
|
|
1584
|
+
|
|
1585
|
+
let pwd = ctx.sessionManager.getCwd();
|
|
1586
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
1587
|
+
if (home && pwd.startsWith(home)) {
|
|
1588
|
+
pwd = `~${pwd.slice(home.length)}`;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const branch = footerData.getGitBranch();
|
|
1592
|
+
if (branch) pwd = `${pwd} (${branch})`;
|
|
1593
|
+
|
|
1594
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
1595
|
+
if (sessionName) pwd = `${pwd} • ${sessionName}`;
|
|
1596
|
+
|
|
1597
|
+
const prefix =
|
|
1598
|
+
mode === "insert"
|
|
1599
|
+
? theme.fg("muted", "-- INSERT -- ")
|
|
1600
|
+
: mode === "replace"
|
|
1601
|
+
? theme.fg("muted", "-- REPLACE -- ")
|
|
1602
|
+
: mode === "visual"
|
|
1603
|
+
? theme.fg("accent", "-- VISUAL -- ")
|
|
1604
|
+
: mode === "visual-line"
|
|
1605
|
+
? theme.fg("accent", "-- VISUAL LINE -- ")
|
|
1606
|
+
: "";
|
|
1607
|
+
const pwdLine = truncateToWidth(prefix + theme.fg("dim", pwd), width, theme.fg("dim", "..."));
|
|
1608
|
+
|
|
1609
|
+
const statsParts: string[] = [];
|
|
1610
|
+
if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
|
|
1611
|
+
if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
|
|
1612
|
+
if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
|
|
1613
|
+
if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
|
|
1614
|
+
|
|
1615
|
+
const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
|
|
1616
|
+
if (totalCost || usingSubscription) {
|
|
1617
|
+
statsParts.push(`$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
const contextPercentDisplay =
|
|
1621
|
+
contextPercent === "?" ? `?/${formatTokens(contextWindow)}` : `${contextPercent}%/${formatTokens(contextWindow)}`;
|
|
1622
|
+
const contextPart =
|
|
1623
|
+
contextPercentValue > 90
|
|
1624
|
+
? theme.fg("error", contextPercentDisplay)
|
|
1625
|
+
: contextPercentValue > 70
|
|
1626
|
+
? theme.fg("warning", contextPercentDisplay)
|
|
1627
|
+
: contextPercentDisplay;
|
|
1628
|
+
statsParts.push(contextPart);
|
|
1629
|
+
|
|
1630
|
+
let statsLeft = statsParts.join(" ");
|
|
1631
|
+
let statsLeftWidth = visibleWidth(statsLeft);
|
|
1632
|
+
if (statsLeftWidth > width) {
|
|
1633
|
+
statsLeft = truncateToWidth(statsLeft, width, "...");
|
|
1634
|
+
statsLeftWidth = visibleWidth(statsLeft);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const modelName = ctx.model?.id || "no-model";
|
|
1638
|
+
const thinkingLevel = pi.getThinkingLevel();
|
|
1639
|
+
let modelRightSide =
|
|
1640
|
+
ctx.model?.reasoning && thinkingLevel !== "off"
|
|
1641
|
+
? `${modelName} • ${thinkingLevel}`
|
|
1642
|
+
: ctx.model?.reasoning
|
|
1643
|
+
? `${modelName} • thinking off`
|
|
1644
|
+
: modelName;
|
|
1645
|
+
|
|
1646
|
+
if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
|
|
1647
|
+
const withProvider = `(${ctx.model.provider}) ${modelRightSide}`;
|
|
1648
|
+
if (statsLeftWidth + 2 + visibleWidth(withProvider) <= width) {
|
|
1649
|
+
modelRightSide = withProvider;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
const pendingPrefix = pendingStatus ? `${pendingStatus}..` : "";
|
|
1653
|
+
const modelWidth = visibleWidth(modelRightSide);
|
|
1654
|
+
const minGap = statsLeftWidth > 0 ? 2 : 0;
|
|
1655
|
+
const modelStart = Math.max(statsLeftWidth + minGap, width - modelWidth);
|
|
1656
|
+
const beforeModelWidth = Math.max(0, modelStart - statsLeftWidth);
|
|
1657
|
+
let beforeModel = " ".repeat(beforeModelWidth);
|
|
1658
|
+
if (pendingPrefix) {
|
|
1659
|
+
const pendingWithSpace = `${pendingPrefix} `;
|
|
1660
|
+
const pendingWidth = visibleWidth(pendingWithSpace);
|
|
1661
|
+
if (pendingWidth <= beforeModelWidth) {
|
|
1662
|
+
beforeModel = `${" ".repeat(beforeModelWidth - pendingWidth)}${pendingWithSpace}`;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
const statsLine = truncateToWidth(`${statsLeft}${beforeModel}${modelRightSide}`, width, "");
|
|
1666
|
+
|
|
1667
|
+
const dimStatsLeft = theme.fg("dim", statsLeft);
|
|
1668
|
+
const middle = statsLine.slice(statsLeft.length, statsLeft.length + beforeModel.length);
|
|
1669
|
+
const right = statsLine.slice(statsLeft.length + beforeModel.length);
|
|
1670
|
+
const coloredMiddle = pendingPrefix && middle.includes(`${pendingPrefix} `)
|
|
1671
|
+
? theme.fg("dim", middle.slice(0, middle.indexOf(`${pendingPrefix} `))) + theme.fg("muted", `${pendingPrefix} `)
|
|
1672
|
+
: theme.fg("dim", middle);
|
|
1673
|
+
const lines = [pwdLine, dimStatsLeft + coloredMiddle + theme.fg("dim", right)];
|
|
1674
|
+
|
|
1675
|
+
const extensionStatuses = footerData.getExtensionStatuses();
|
|
1676
|
+
if (extensionStatuses.size > 0) {
|
|
1677
|
+
const statusLine = Array.from(extensionStatuses.entries())
|
|
1678
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1679
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
1680
|
+
.join(" ");
|
|
1681
|
+
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
return lines;
|
|
1685
|
+
},
|
|
1686
|
+
};
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1689
|
+
ctx.ui.setEditorComponent(
|
|
1690
|
+
(tui, theme, keybindings) =>
|
|
1691
|
+
new VimModeEditor(tui, theme, keybindings, (nextMode, nextPending) => {
|
|
1692
|
+
mode = nextMode;
|
|
1693
|
+
pendingStatus = nextPending;
|
|
1694
|
+
}),
|
|
1695
|
+
);
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
pi.registerCommand("vim-mode", {
|
|
1699
|
+
description: "Toggle vim mode",
|
|
1700
|
+
handler: async (_args, ctx) => {
|
|
1701
|
+
enabled = !enabled;
|
|
1702
|
+
applyVimMode(ctx);
|
|
1703
|
+
ctx.ui.notify(`Vim mode ${enabled ? "enabled" : "disabled"}.`, "info");
|
|
1704
|
+
},
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1708
|
+
applyVimMode(ctx);
|
|
1709
|
+
});
|
|
1710
|
+
}
|