@oh-my-pi/pi-tui 5.5.0 → 5.6.70
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/package.json +1 -1
- package/src/components/cancellable-loader.ts +2 -2
- package/src/components/editor.ts +314 -91
- package/src/components/input.ts +9 -3
- package/src/components/select-list.ts +5 -5
- package/src/components/settings-list.ts +5 -5
- package/src/components/tab-bar.ts +9 -6
- package/src/index.ts +1 -43
- package/src/keybindings.ts +30 -2
- package/src/keys.ts +416 -237
- package/src/terminal.ts +2 -1
- package/src/tui.ts +159 -69
package/src/components/editor.ts
CHANGED
|
@@ -1,35 +1,7 @@
|
|
|
1
1
|
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
|
|
2
|
-
import {
|
|
3
|
-
isAltBackspace,
|
|
4
|
-
isAltEnter,
|
|
5
|
-
isAltLeft,
|
|
6
|
-
isAltRight,
|
|
7
|
-
isArrowDown,
|
|
8
|
-
isArrowLeft,
|
|
9
|
-
isArrowRight,
|
|
10
|
-
isArrowUp,
|
|
11
|
-
isBackspace,
|
|
12
|
-
isCtrlA,
|
|
13
|
-
isCtrlC,
|
|
14
|
-
isCtrlE,
|
|
15
|
-
isCtrlK,
|
|
16
|
-
isCtrlLeft,
|
|
17
|
-
isCtrlRight,
|
|
18
|
-
isCtrlU,
|
|
19
|
-
isCtrlW,
|
|
20
|
-
isDelete,
|
|
21
|
-
isEnd,
|
|
22
|
-
isEnter,
|
|
23
|
-
isEscape,
|
|
24
|
-
isHome,
|
|
25
|
-
isShiftBackspace,
|
|
26
|
-
isShiftDelete,
|
|
27
|
-
isShiftEnter,
|
|
28
|
-
isShiftSpace,
|
|
29
|
-
isTab,
|
|
30
|
-
} from "../keys";
|
|
2
|
+
import { matchesKey } from "../keys";
|
|
31
3
|
import type { SymbolTheme } from "../symbols";
|
|
32
|
-
import type
|
|
4
|
+
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
|
|
33
5
|
import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils";
|
|
34
6
|
import { SelectList, type SelectListTheme } from "./select-list";
|
|
35
7
|
|
|
@@ -215,6 +187,44 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
215
187
|
return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }];
|
|
216
188
|
}
|
|
217
189
|
|
|
190
|
+
// Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints.
|
|
191
|
+
const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/;
|
|
192
|
+
const KITTY_MOD_SHIFT = 1;
|
|
193
|
+
const KITTY_MOD_ALT = 2;
|
|
194
|
+
const KITTY_MOD_CTRL = 4;
|
|
195
|
+
|
|
196
|
+
// Decode a printable CSI-u sequence, preferring the shifted key when present.
|
|
197
|
+
function decodeKittyPrintable(data: string): string | undefined {
|
|
198
|
+
const match = data.match(KITTY_CSI_U_REGEX);
|
|
199
|
+
if (!match) return undefined;
|
|
200
|
+
|
|
201
|
+
// CSI-u groups: <codepoint>[:<shifted>[:<base>]];<mod>u
|
|
202
|
+
const codepoint = Number.parseInt(match[1] ?? "", 10);
|
|
203
|
+
if (!Number.isFinite(codepoint)) return undefined;
|
|
204
|
+
|
|
205
|
+
const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined;
|
|
206
|
+
const modValue = match[4] ? Number.parseInt(match[4], 10) : 1;
|
|
207
|
+
// Modifiers are 1-indexed in CSI-u; normalize to our bitmask.
|
|
208
|
+
const modifier = Number.isFinite(modValue) ? modValue - 1 : 0;
|
|
209
|
+
|
|
210
|
+
// Ignore CSI-u sequences used for Alt/Ctrl shortcuts.
|
|
211
|
+
if (modifier & (KITTY_MOD_ALT | KITTY_MOD_CTRL)) return undefined;
|
|
212
|
+
|
|
213
|
+
// Prefer the shifted keycode when Shift is held.
|
|
214
|
+
let effectiveCodepoint = codepoint;
|
|
215
|
+
if (modifier & KITTY_MOD_SHIFT && typeof shiftedKey === "number") {
|
|
216
|
+
effectiveCodepoint = shiftedKey;
|
|
217
|
+
}
|
|
218
|
+
// Drop control characters or invalid codepoints.
|
|
219
|
+
if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined;
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
return String.fromCodePoint(effectiveCodepoint);
|
|
223
|
+
} catch {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
218
228
|
interface EditorState {
|
|
219
229
|
lines: string[];
|
|
220
230
|
cursorLine: number;
|
|
@@ -231,6 +241,7 @@ export interface EditorTheme {
|
|
|
231
241
|
borderColor: (str: string) => string;
|
|
232
242
|
selectList: SelectListTheme;
|
|
233
243
|
symbols: SymbolTheme;
|
|
244
|
+
editorPaddingX?: number;
|
|
234
245
|
}
|
|
235
246
|
|
|
236
247
|
export interface EditorTopBorder {
|
|
@@ -249,18 +260,27 @@ interface HistoryStorage {
|
|
|
249
260
|
getRecent(limit: number): HistoryEntry[];
|
|
250
261
|
}
|
|
251
262
|
|
|
252
|
-
export class Editor implements Component {
|
|
263
|
+
export class Editor implements Component, Focusable {
|
|
253
264
|
private state: EditorState = {
|
|
254
265
|
lines: [""],
|
|
255
266
|
cursorLine: 0,
|
|
256
267
|
cursorCol: 0,
|
|
257
268
|
};
|
|
258
269
|
|
|
270
|
+
/** Focusable interface - set by TUI when focus changes */
|
|
271
|
+
focused: boolean = false;
|
|
272
|
+
|
|
259
273
|
private theme: EditorTheme;
|
|
260
274
|
private useTerminalCursor = false;
|
|
261
275
|
|
|
262
276
|
// Store last render width for cursor navigation
|
|
263
277
|
private lastWidth: number = 80;
|
|
278
|
+
private maxHeight?: number;
|
|
279
|
+
private scrollOffset: number = 0;
|
|
280
|
+
|
|
281
|
+
// Emacs-style kill ring
|
|
282
|
+
private killRing: string[] = [];
|
|
283
|
+
private lastKillWasKillCommand: boolean = false;
|
|
264
284
|
|
|
265
285
|
// Border color (can be changed dynamically)
|
|
266
286
|
public borderColor: (str: string) => string;
|
|
@@ -318,6 +338,11 @@ export class Editor implements Component {
|
|
|
318
338
|
this.useTerminalCursor = useTerminalCursor;
|
|
319
339
|
}
|
|
320
340
|
|
|
341
|
+
setMaxHeight(maxHeight: number | undefined): void {
|
|
342
|
+
this.maxHeight = maxHeight;
|
|
343
|
+
this.scrollOffset = 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
321
346
|
setHistoryStorage(storage: HistoryStorage): void {
|
|
322
347
|
this.historyStorage = storage;
|
|
323
348
|
const recent = storage.getRecent(100);
|
|
@@ -348,18 +373,21 @@ export class Editor implements Component {
|
|
|
348
373
|
}
|
|
349
374
|
|
|
350
375
|
private isOnFirstVisualLine(): boolean {
|
|
351
|
-
const
|
|
376
|
+
const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX());
|
|
377
|
+
const visualLines = this.buildVisualLineMap(contentWidth);
|
|
352
378
|
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
353
379
|
return currentVisualLine === 0;
|
|
354
380
|
}
|
|
355
381
|
|
|
356
382
|
private isOnLastVisualLine(): boolean {
|
|
357
|
-
const
|
|
383
|
+
const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX());
|
|
384
|
+
const visualLines = this.buildVisualLineMap(contentWidth);
|
|
358
385
|
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
359
386
|
return currentVisualLine === visualLines.length - 1;
|
|
360
387
|
}
|
|
361
388
|
|
|
362
389
|
private navigateHistory(direction: 1 | -1): void {
|
|
390
|
+
this.resetKillSequence();
|
|
363
391
|
if (this.history.length === 0) return;
|
|
364
392
|
|
|
365
393
|
const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
|
|
@@ -391,27 +419,65 @@ export class Editor implements Component {
|
|
|
391
419
|
// No cached state to invalidate currently
|
|
392
420
|
}
|
|
393
421
|
|
|
422
|
+
private getEditorPaddingX(): number {
|
|
423
|
+
const padding = this.theme.editorPaddingX ?? 2;
|
|
424
|
+
return Math.max(1, padding);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private getContentWidth(width: number, paddingX: number): number {
|
|
428
|
+
return Math.max(0, width - 2 * (paddingX + 1));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private getVisibleContentHeight(contentLines: number): number {
|
|
432
|
+
if (this.maxHeight === undefined) return contentLines;
|
|
433
|
+
return Math.max(1, this.maxHeight - 2);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private updateScrollOffset(contentWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void {
|
|
437
|
+
if (layoutLines.length <= visibleHeight) {
|
|
438
|
+
this.scrollOffset = 0;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const visualLines = this.buildVisualLineMap(contentWidth);
|
|
443
|
+
const cursorLine = this.findCurrentVisualLine(visualLines);
|
|
444
|
+
if (cursorLine < this.scrollOffset) {
|
|
445
|
+
this.scrollOffset = cursorLine;
|
|
446
|
+
} else if (cursorLine >= this.scrollOffset + visibleHeight) {
|
|
447
|
+
this.scrollOffset = cursorLine - visibleHeight + 1;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const maxOffset = Math.max(0, layoutLines.length - visibleHeight);
|
|
451
|
+
this.scrollOffset = Math.min(this.scrollOffset, maxOffset);
|
|
452
|
+
}
|
|
453
|
+
|
|
394
454
|
render(width: number): string[] {
|
|
395
455
|
// Store width for cursor navigation
|
|
396
456
|
this.lastWidth = width;
|
|
397
457
|
|
|
398
458
|
// Box-drawing characters for rounded corners
|
|
399
459
|
const box = this.theme.symbols.boxRound;
|
|
400
|
-
const
|
|
401
|
-
const
|
|
402
|
-
const
|
|
403
|
-
const
|
|
460
|
+
const paddingX = this.getEditorPaddingX();
|
|
461
|
+
const borderWidth = paddingX + 1;
|
|
462
|
+
const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
|
|
463
|
+
const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
|
|
464
|
+
const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${" ".repeat(Math.max(0, paddingX - 1))}`);
|
|
465
|
+
const bottomRight = this.borderColor(
|
|
466
|
+
`${" ".repeat(Math.max(0, paddingX - 1))}${box.horizontal}${box.bottomRight}`,
|
|
467
|
+
);
|
|
404
468
|
const horizontal = this.borderColor(box.horizontal);
|
|
405
469
|
|
|
406
|
-
// Layout the text
|
|
407
|
-
const contentAreaWidth = width
|
|
470
|
+
// Layout the text
|
|
471
|
+
const contentAreaWidth = this.getContentWidth(width, paddingX);
|
|
408
472
|
const layoutLines = this.layoutText(contentAreaWidth);
|
|
473
|
+
const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length);
|
|
474
|
+
this.updateScrollOffset(contentAreaWidth, layoutLines, visibleContentHeight);
|
|
475
|
+
const visibleLayoutLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + visibleContentHeight);
|
|
409
476
|
|
|
410
477
|
const result: string[] = [];
|
|
411
478
|
|
|
412
479
|
// Render top border: ╭─ [status content] ────────────────╮
|
|
413
|
-
|
|
414
|
-
const topFillWidth = width - 4;
|
|
480
|
+
const topFillWidth = width - borderWidth * 2;
|
|
415
481
|
if (this.topBorderContent) {
|
|
416
482
|
const { content, width: statusWidth } = this.topBorderContent;
|
|
417
483
|
if (statusWidth <= topFillWidth) {
|
|
@@ -430,9 +496,11 @@ export class Editor implements Component {
|
|
|
430
496
|
}
|
|
431
497
|
|
|
432
498
|
// Render each layout line
|
|
433
|
-
//
|
|
434
|
-
const
|
|
435
|
-
|
|
499
|
+
// Emit hardware cursor marker only when focused and not showing autocomplete
|
|
500
|
+
const emitCursorMarker = this.focused && !this.isAutocompleting;
|
|
501
|
+
const lineContentWidth = contentAreaWidth;
|
|
502
|
+
|
|
503
|
+
for (const layoutLine of visibleLayoutLines) {
|
|
436
504
|
let displayText = layoutLine.text;
|
|
437
505
|
let displayWidth = visibleWidth(layoutLine.text);
|
|
438
506
|
|
|
@@ -441,6 +509,9 @@ export class Editor implements Component {
|
|
|
441
509
|
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
442
510
|
const after = displayText.slice(layoutLine.cursorPos);
|
|
443
511
|
|
|
512
|
+
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
|
|
513
|
+
const marker = emitCursorMarker ? CURSOR_MARKER : "";
|
|
514
|
+
|
|
444
515
|
if (after.length > 0) {
|
|
445
516
|
// Cursor is on a character (grapheme) - replace it with highlighted version
|
|
446
517
|
// Get the first grapheme from 'after'
|
|
@@ -448,13 +519,13 @@ export class Editor implements Component {
|
|
|
448
519
|
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
|
449
520
|
const restAfter = after.slice(firstGrapheme.length);
|
|
450
521
|
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
|
451
|
-
displayText = before + cursor + restAfter;
|
|
522
|
+
displayText = before + marker + cursor + restAfter;
|
|
452
523
|
// displayWidth stays the same - we're replacing, not adding
|
|
453
524
|
} else {
|
|
454
525
|
// Cursor is at the end - add thin blinking bar cursor
|
|
455
526
|
const cursorChar = this.theme.symbols.inputCursor;
|
|
456
527
|
const cursor = `\x1b[5m${cursorChar}\x1b[0m`;
|
|
457
|
-
displayText = before + cursor;
|
|
528
|
+
displayText = before + marker + cursor;
|
|
458
529
|
displayWidth += visibleWidth(cursorChar);
|
|
459
530
|
if (displayWidth > lineContentWidth) {
|
|
460
531
|
// Line is at full width - use reverse video on last grapheme if possible
|
|
@@ -468,23 +539,22 @@ export class Editor implements Component {
|
|
|
468
539
|
.slice(0, -1)
|
|
469
540
|
.map((g) => g.segment)
|
|
470
541
|
.join("");
|
|
471
|
-
displayText = beforeWithoutLast + cursor;
|
|
542
|
+
displayText = beforeWithoutLast + marker + cursor;
|
|
472
543
|
displayWidth -= 1; // Back to original width (reverse video replaces, doesn't add)
|
|
473
544
|
}
|
|
474
545
|
}
|
|
475
546
|
}
|
|
476
547
|
}
|
|
477
548
|
|
|
478
|
-
// All lines have consistent
|
|
479
|
-
const isLastLine = layoutLine ===
|
|
549
|
+
// All lines have consistent borders based on padding
|
|
550
|
+
const isLastLine = layoutLine === visibleLayoutLines[visibleLayoutLines.length - 1];
|
|
480
551
|
const padding = " ".repeat(Math.max(0, lineContentWidth - displayWidth));
|
|
481
552
|
|
|
482
553
|
if (isLastLine) {
|
|
483
|
-
|
|
484
|
-
result.push(`${bottomLeft} ${displayText}${padding} ${bottomRight}`);
|
|
554
|
+
result.push(`${bottomLeft}${displayText}${padding}${bottomRight}`);
|
|
485
555
|
} else {
|
|
486
|
-
const leftBorder = this.borderColor(`${box.vertical}
|
|
487
|
-
const rightBorder = this.borderColor(
|
|
556
|
+
const leftBorder = this.borderColor(`${box.vertical}${" ".repeat(paddingX)}`);
|
|
557
|
+
const rightBorder = this.borderColor(`${" ".repeat(paddingX)}${box.vertical}`);
|
|
488
558
|
result.push(leftBorder + displayText + padding + rightBorder);
|
|
489
559
|
}
|
|
490
560
|
}
|
|
@@ -501,11 +571,17 @@ export class Editor implements Component {
|
|
|
501
571
|
getCursorPosition(width: number): { row: number; col: number } | null {
|
|
502
572
|
if (!this.useTerminalCursor) return null;
|
|
503
573
|
|
|
504
|
-
const
|
|
574
|
+
const paddingX = this.getEditorPaddingX();
|
|
575
|
+
const borderWidth = paddingX + 1;
|
|
576
|
+
const contentWidth = this.getContentWidth(width, paddingX);
|
|
505
577
|
if (contentWidth <= 0) return null;
|
|
506
578
|
|
|
507
579
|
const layoutLines = this.layoutText(contentWidth);
|
|
580
|
+
const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length);
|
|
581
|
+
this.updateScrollOffset(contentWidth, layoutLines, visibleContentHeight);
|
|
582
|
+
|
|
508
583
|
for (let i = 0; i < layoutLines.length; i++) {
|
|
584
|
+
if (i < this.scrollOffset || i >= this.scrollOffset + visibleContentHeight) continue;
|
|
509
585
|
const layoutLine = layoutLines[i];
|
|
510
586
|
if (!layoutLine || !layoutLine.hasCursor || layoutLine.cursorPos === undefined) continue;
|
|
511
587
|
|
|
@@ -516,13 +592,13 @@ export class Editor implements Component {
|
|
|
516
592
|
const graphemes = [...segmenter.segment(layoutLine.text)];
|
|
517
593
|
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
|
518
594
|
const lastWidth = visibleWidth(lastGrapheme) || 1;
|
|
519
|
-
const colOffset =
|
|
520
|
-
return { row: 1 + i, col: colOffset };
|
|
595
|
+
const colOffset = borderWidth + Math.max(0, lineWidth - lastWidth);
|
|
596
|
+
return { row: 1 + i - this.scrollOffset, col: colOffset };
|
|
521
597
|
}
|
|
522
598
|
|
|
523
599
|
const before = layoutLine.text.slice(0, layoutLine.cursorPos);
|
|
524
|
-
const colOffset =
|
|
525
|
-
return { row: 1 + i, col: colOffset };
|
|
600
|
+
const colOffset = borderWidth + visibleWidth(before);
|
|
601
|
+
return { row: 1 + i - this.scrollOffset, col: colOffset };
|
|
526
602
|
}
|
|
527
603
|
|
|
528
604
|
return null;
|
|
@@ -590,27 +666,34 @@ export class Editor implements Component {
|
|
|
590
666
|
}
|
|
591
667
|
|
|
592
668
|
// Ctrl+C - Exit (let parent handle this)
|
|
593
|
-
if (
|
|
669
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
594
670
|
return;
|
|
595
671
|
}
|
|
596
672
|
|
|
597
673
|
// Handle autocomplete special keys first (but don't block other input)
|
|
598
674
|
if (this.isAutocompleting && this.autocompleteList) {
|
|
599
675
|
// Escape - cancel autocomplete
|
|
600
|
-
if (
|
|
676
|
+
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
|
|
601
677
|
this.cancelAutocomplete(true);
|
|
602
678
|
return;
|
|
603
679
|
}
|
|
604
680
|
// Let the autocomplete list handle navigation and selection
|
|
605
|
-
else if (
|
|
681
|
+
else if (
|
|
682
|
+
matchesKey(data, "up") ||
|
|
683
|
+
matchesKey(data, "down") ||
|
|
684
|
+
matchesKey(data, "enter") ||
|
|
685
|
+
matchesKey(data, "return") ||
|
|
686
|
+
data === "\n" ||
|
|
687
|
+
matchesKey(data, "tab")
|
|
688
|
+
) {
|
|
606
689
|
// Only pass arrow keys to the list, not Enter/Tab (we handle those directly)
|
|
607
|
-
if (
|
|
690
|
+
if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
608
691
|
this.autocompleteList.handleInput(data);
|
|
609
692
|
return;
|
|
610
693
|
}
|
|
611
694
|
|
|
612
695
|
// If Tab was pressed, always apply the selection
|
|
613
|
-
if (
|
|
696
|
+
if (matchesKey(data, "tab")) {
|
|
614
697
|
const selected = this.autocompleteList.getSelectedItem();
|
|
615
698
|
if (selected && this.autocompleteProvider) {
|
|
616
699
|
const result = this.autocompleteProvider.applyCompletion(
|
|
@@ -635,7 +718,10 @@ export class Editor implements Component {
|
|
|
635
718
|
}
|
|
636
719
|
|
|
637
720
|
// If Enter was pressed on a slash command, apply completion and submit
|
|
638
|
-
if (
|
|
721
|
+
if (
|
|
722
|
+
(matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") &&
|
|
723
|
+
this.autocompletePrefix.startsWith("/")
|
|
724
|
+
) {
|
|
639
725
|
const selected = this.autocompleteList.getSelectedItem();
|
|
640
726
|
if (selected && this.autocompleteProvider) {
|
|
641
727
|
const result = this.autocompleteProvider.applyCompletion(
|
|
@@ -654,7 +740,7 @@ export class Editor implements Component {
|
|
|
654
740
|
// Don't return - fall through to submission logic
|
|
655
741
|
}
|
|
656
742
|
// If Enter was pressed on a file path, apply completion
|
|
657
|
-
else if (
|
|
743
|
+
else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
658
744
|
const selected = this.autocompleteList.getSelectedItem();
|
|
659
745
|
if (selected && this.autocompleteProvider) {
|
|
660
746
|
const result = this.autocompleteProvider.applyCompletion(
|
|
@@ -683,38 +769,46 @@ export class Editor implements Component {
|
|
|
683
769
|
}
|
|
684
770
|
|
|
685
771
|
// Tab key - context-aware completion (but not when already autocompleting)
|
|
686
|
-
if (
|
|
772
|
+
if (matchesKey(data, "tab") && !this.isAutocompleting) {
|
|
687
773
|
this.handleTabCompletion();
|
|
688
774
|
return;
|
|
689
775
|
}
|
|
690
776
|
|
|
691
777
|
// Continue with rest of input handling
|
|
692
778
|
// Ctrl+K - Delete to end of line
|
|
693
|
-
if (
|
|
779
|
+
if (matchesKey(data, "ctrl+k")) {
|
|
694
780
|
this.deleteToEndOfLine();
|
|
695
781
|
}
|
|
696
782
|
// Ctrl+U - Delete to start of line
|
|
697
|
-
else if (
|
|
783
|
+
else if (matchesKey(data, "ctrl+u")) {
|
|
698
784
|
this.deleteToStartOfLine();
|
|
699
785
|
}
|
|
700
786
|
// Ctrl+W - Delete word backwards
|
|
701
|
-
else if (
|
|
787
|
+
else if (matchesKey(data, "ctrl+w")) {
|
|
702
788
|
this.deleteWordBackwards();
|
|
703
789
|
}
|
|
704
790
|
// Option/Alt+Backspace - Delete word backwards
|
|
705
|
-
else if (
|
|
791
|
+
else if (matchesKey(data, "alt+backspace")) {
|
|
706
792
|
this.deleteWordBackwards();
|
|
707
793
|
}
|
|
794
|
+
// Option/Alt+D - Delete word forwards
|
|
795
|
+
else if (matchesKey(data, "alt+d")) {
|
|
796
|
+
this.deleteWordForwards();
|
|
797
|
+
}
|
|
798
|
+
// Ctrl+Y - Yank from kill ring
|
|
799
|
+
else if (matchesKey(data, "ctrl+y")) {
|
|
800
|
+
this.yankFromKillRing();
|
|
801
|
+
}
|
|
708
802
|
// Ctrl+A - Move to start of line
|
|
709
|
-
else if (
|
|
803
|
+
else if (matchesKey(data, "ctrl+a")) {
|
|
710
804
|
this.moveToLineStart();
|
|
711
805
|
}
|
|
712
806
|
// Ctrl+E - Move to end of line
|
|
713
|
-
else if (
|
|
807
|
+
else if (matchesKey(data, "ctrl+e")) {
|
|
714
808
|
this.moveToLineEnd();
|
|
715
809
|
}
|
|
716
810
|
// Alt+Enter - special handler if callback exists, otherwise new line
|
|
717
|
-
else if (
|
|
811
|
+
else if (matchesKey(data, "alt+enter")) {
|
|
718
812
|
if (this.onAltEnter) {
|
|
719
813
|
this.onAltEnter(this.getText());
|
|
720
814
|
} else {
|
|
@@ -728,7 +822,7 @@ export class Editor implements Component {
|
|
|
728
822
|
data === "\x1b[27;5;13~" || // Ctrl+Enter (legacy format)
|
|
729
823
|
data === "\x1b\r" || // Option+Enter in some terminals (legacy)
|
|
730
824
|
data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
|
|
731
|
-
|
|
825
|
+
matchesKey(data, "shift+enter") || // Shift+Enter (Kitty protocol, handles lock bits)
|
|
732
826
|
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
|
|
733
827
|
(data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping
|
|
734
828
|
data === "\\\r" // Shift+Enter in VS Code terminal
|
|
@@ -737,12 +831,14 @@ export class Editor implements Component {
|
|
|
737
831
|
this.addNewLine();
|
|
738
832
|
}
|
|
739
833
|
// Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
|
|
740
|
-
else if (
|
|
834
|
+
else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
741
835
|
// If submit is disabled, do nothing
|
|
742
836
|
if (this.disableSubmit) {
|
|
743
837
|
return;
|
|
744
838
|
}
|
|
745
839
|
|
|
840
|
+
this.resetKillSequence();
|
|
841
|
+
|
|
746
842
|
// Get text and substitute paste markers with actual content
|
|
747
843
|
let result = this.state.lines.join("\n").trim();
|
|
748
844
|
|
|
@@ -773,29 +869,31 @@ export class Editor implements Component {
|
|
|
773
869
|
}
|
|
774
870
|
}
|
|
775
871
|
// Backspace (including Shift+Backspace)
|
|
776
|
-
else if (
|
|
872
|
+
else if (matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) {
|
|
777
873
|
this.handleBackspace();
|
|
778
874
|
}
|
|
779
875
|
// Line navigation shortcuts (Home/End keys)
|
|
780
|
-
else if (
|
|
876
|
+
else if (matchesKey(data, "home")) {
|
|
781
877
|
this.moveToLineStart();
|
|
782
|
-
} else if (
|
|
878
|
+
} else if (matchesKey(data, "end")) {
|
|
783
879
|
this.moveToLineEnd();
|
|
784
880
|
}
|
|
785
881
|
// Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
|
|
786
|
-
else if (
|
|
882
|
+
else if (matchesKey(data, "delete") || matchesKey(data, "shift+delete")) {
|
|
787
883
|
this.handleForwardDelete();
|
|
788
884
|
}
|
|
789
885
|
// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
|
|
790
|
-
else if (
|
|
886
|
+
else if (matchesKey(data, "alt+left") || matchesKey(data, "ctrl+left")) {
|
|
791
887
|
// Word left
|
|
888
|
+
this.resetKillSequence();
|
|
792
889
|
this.moveWordBackwards();
|
|
793
|
-
} else if (
|
|
890
|
+
} else if (matchesKey(data, "alt+right") || matchesKey(data, "ctrl+right")) {
|
|
794
891
|
// Word right
|
|
892
|
+
this.resetKillSequence();
|
|
795
893
|
this.moveWordForwards();
|
|
796
894
|
}
|
|
797
895
|
// Arrow keys
|
|
798
|
-
else if (
|
|
896
|
+
else if (matchesKey(data, "up")) {
|
|
799
897
|
// Up - history navigation or cursor movement
|
|
800
898
|
if (this.isEditorEmpty()) {
|
|
801
899
|
this.navigateHistory(-1); // Start browsing history
|
|
@@ -804,27 +902,35 @@ export class Editor implements Component {
|
|
|
804
902
|
} else {
|
|
805
903
|
this.moveCursor(-1, 0); // Cursor movement (within text or history entry)
|
|
806
904
|
}
|
|
807
|
-
} else if (
|
|
905
|
+
} else if (matchesKey(data, "down")) {
|
|
808
906
|
// Down - history navigation or cursor movement
|
|
809
907
|
if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
|
|
810
908
|
this.navigateHistory(1); // Navigate to newer history entry or clear
|
|
811
909
|
} else {
|
|
812
910
|
this.moveCursor(1, 0); // Cursor movement (within text or history entry)
|
|
813
911
|
}
|
|
814
|
-
} else if (
|
|
912
|
+
} else if (matchesKey(data, "right")) {
|
|
815
913
|
// Right
|
|
816
914
|
this.moveCursor(0, 1);
|
|
817
|
-
} else if (
|
|
915
|
+
} else if (matchesKey(data, "left")) {
|
|
818
916
|
// Left
|
|
819
917
|
this.moveCursor(0, -1);
|
|
820
918
|
}
|
|
821
919
|
// Shift+Space - insert regular space (Kitty protocol sends escape sequence)
|
|
822
|
-
else if (
|
|
920
|
+
else if (matchesKey(data, "shift+space")) {
|
|
823
921
|
this.insertCharacter(" ");
|
|
824
922
|
}
|
|
825
|
-
//
|
|
826
|
-
else
|
|
827
|
-
|
|
923
|
+
// Kitty CSI-u printable characters (shifted symbols like @, ?, {, })
|
|
924
|
+
else {
|
|
925
|
+
const kittyChar = decodeKittyPrintable(data);
|
|
926
|
+
if (kittyChar) {
|
|
927
|
+
this.insertText(kittyChar);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
// Regular characters (printable characters and unicode, but not control characters)
|
|
931
|
+
if (data.charCodeAt(0) >= 32) {
|
|
932
|
+
this.insertCharacter(data);
|
|
933
|
+
}
|
|
828
934
|
}
|
|
829
935
|
}
|
|
830
936
|
|
|
@@ -943,12 +1049,14 @@ export class Editor implements Component {
|
|
|
943
1049
|
|
|
944
1050
|
setText(text: string): void {
|
|
945
1051
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1052
|
+
this.resetKillSequence();
|
|
946
1053
|
this.setTextInternal(text);
|
|
947
1054
|
}
|
|
948
1055
|
|
|
949
1056
|
/** Insert text at the current cursor position */
|
|
950
1057
|
insertText(text: string): void {
|
|
951
1058
|
this.historyIndex = -1;
|
|
1059
|
+
this.resetKillSequence();
|
|
952
1060
|
|
|
953
1061
|
const line = this.state.lines[this.state.cursorLine] || "";
|
|
954
1062
|
const before = line.slice(0, this.state.cursorCol);
|
|
@@ -965,6 +1073,7 @@ export class Editor implements Component {
|
|
|
965
1073
|
// All the editor methods from before...
|
|
966
1074
|
private insertCharacter(char: string): void {
|
|
967
1075
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1076
|
+
this.resetKillSequence();
|
|
968
1077
|
|
|
969
1078
|
const line = this.state.lines[this.state.cursorLine] || "";
|
|
970
1079
|
|
|
@@ -1014,6 +1123,7 @@ export class Editor implements Component {
|
|
|
1014
1123
|
|
|
1015
1124
|
private handlePaste(pastedText: string): void {
|
|
1016
1125
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1126
|
+
this.resetKillSequence();
|
|
1017
1127
|
|
|
1018
1128
|
// Clean the pasted text
|
|
1019
1129
|
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
@@ -1114,6 +1224,7 @@ export class Editor implements Component {
|
|
|
1114
1224
|
|
|
1115
1225
|
private addNewLine(): void {
|
|
1116
1226
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1227
|
+
this.resetKillSequence();
|
|
1117
1228
|
|
|
1118
1229
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1119
1230
|
|
|
@@ -1135,6 +1246,7 @@ export class Editor implements Component {
|
|
|
1135
1246
|
|
|
1136
1247
|
private handleBackspace(): void {
|
|
1137
1248
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1249
|
+
this.resetKillSequence();
|
|
1138
1250
|
|
|
1139
1251
|
if (this.state.cursorCol > 0) {
|
|
1140
1252
|
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
|
@@ -1186,25 +1298,96 @@ export class Editor implements Component {
|
|
|
1186
1298
|
}
|
|
1187
1299
|
|
|
1188
1300
|
private moveToLineStart(): void {
|
|
1301
|
+
this.resetKillSequence();
|
|
1189
1302
|
this.state.cursorCol = 0;
|
|
1190
1303
|
}
|
|
1191
1304
|
|
|
1192
1305
|
private moveToLineEnd(): void {
|
|
1306
|
+
this.resetKillSequence();
|
|
1193
1307
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1194
1308
|
this.state.cursorCol = currentLine.length;
|
|
1195
1309
|
}
|
|
1196
1310
|
|
|
1311
|
+
private resetKillSequence(): void {
|
|
1312
|
+
this.lastKillWasKillCommand = false;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
private recordKill(text: string, direction: "forward" | "backward"): void {
|
|
1316
|
+
if (!text) return;
|
|
1317
|
+
if (this.lastKillWasKillCommand && this.killRing.length > 0) {
|
|
1318
|
+
if (direction === "backward") {
|
|
1319
|
+
this.killRing[0] = text + this.killRing[0];
|
|
1320
|
+
} else {
|
|
1321
|
+
this.killRing[0] = this.killRing[0] + text;
|
|
1322
|
+
}
|
|
1323
|
+
} else {
|
|
1324
|
+
this.killRing.unshift(text);
|
|
1325
|
+
}
|
|
1326
|
+
this.lastKillWasKillCommand = true;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
private insertTextAtCursor(text: string): void {
|
|
1330
|
+
this.historyIndex = -1;
|
|
1331
|
+
this.resetKillSequence();
|
|
1332
|
+
|
|
1333
|
+
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1334
|
+
const lines = normalized.split("\n");
|
|
1335
|
+
|
|
1336
|
+
if (lines.length === 1) {
|
|
1337
|
+
const line = this.state.lines[this.state.cursorLine] || "";
|
|
1338
|
+
const before = line.slice(0, this.state.cursorCol);
|
|
1339
|
+
const after = line.slice(this.state.cursorCol);
|
|
1340
|
+
this.state.lines[this.state.cursorLine] = before + normalized + after;
|
|
1341
|
+
this.state.cursorCol += normalized.length;
|
|
1342
|
+
} else {
|
|
1343
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1344
|
+
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
|
1345
|
+
const afterCursor = currentLine.slice(this.state.cursorCol);
|
|
1346
|
+
|
|
1347
|
+
const newLines: string[] = [];
|
|
1348
|
+
for (let i = 0; i < this.state.cursorLine; i++) {
|
|
1349
|
+
newLines.push(this.state.lines[i] || "");
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
newLines.push(beforeCursor + (lines[0] || ""));
|
|
1353
|
+
for (let i = 1; i < lines.length - 1; i++) {
|
|
1354
|
+
newLines.push(lines[i] || "");
|
|
1355
|
+
}
|
|
1356
|
+
newLines.push((lines[lines.length - 1] || "") + afterCursor);
|
|
1357
|
+
|
|
1358
|
+
for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
|
|
1359
|
+
newLines.push(this.state.lines[i] || "");
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
this.state.lines = newLines;
|
|
1363
|
+
this.state.cursorLine += lines.length - 1;
|
|
1364
|
+
this.state.cursorCol = (lines[lines.length - 1] || "").length;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
if (this.onChange) {
|
|
1368
|
+
this.onChange(this.getText());
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
private yankFromKillRing(): void {
|
|
1373
|
+
if (this.killRing.length === 0) return;
|
|
1374
|
+
this.insertTextAtCursor(this.killRing[0] || "");
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1197
1377
|
private deleteToStartOfLine(): void {
|
|
1198
1378
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1199
1379
|
|
|
1200
1380
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1381
|
+
let deletedText = "";
|
|
1201
1382
|
|
|
1202
1383
|
if (this.state.cursorCol > 0) {
|
|
1203
1384
|
// Delete from start of line up to cursor
|
|
1385
|
+
deletedText = currentLine.slice(0, this.state.cursorCol);
|
|
1204
1386
|
this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
|
|
1205
1387
|
this.state.cursorCol = 0;
|
|
1206
1388
|
} else if (this.state.cursorLine > 0) {
|
|
1207
1389
|
// At start of line - merge with previous line
|
|
1390
|
+
deletedText = "\n";
|
|
1208
1391
|
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
|
1209
1392
|
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
1210
1393
|
this.state.lines.splice(this.state.cursorLine, 1);
|
|
@@ -1212,6 +1395,8 @@ export class Editor implements Component {
|
|
|
1212
1395
|
this.state.cursorCol = previousLine.length;
|
|
1213
1396
|
}
|
|
1214
1397
|
|
|
1398
|
+
this.recordKill(deletedText, "backward");
|
|
1399
|
+
|
|
1215
1400
|
if (this.onChange) {
|
|
1216
1401
|
this.onChange(this.getText());
|
|
1217
1402
|
}
|
|
@@ -1221,17 +1406,22 @@ export class Editor implements Component {
|
|
|
1221
1406
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1222
1407
|
|
|
1223
1408
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1409
|
+
let deletedText = "";
|
|
1224
1410
|
|
|
1225
1411
|
if (this.state.cursorCol < currentLine.length) {
|
|
1226
1412
|
// Delete from cursor to end of line
|
|
1413
|
+
deletedText = currentLine.slice(this.state.cursorCol);
|
|
1227
1414
|
this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
|
|
1228
1415
|
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1229
1416
|
// At end of line - merge with next line
|
|
1230
1417
|
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
|
1418
|
+
deletedText = `\n${nextLine}`;
|
|
1231
1419
|
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
|
1232
1420
|
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
|
1233
1421
|
}
|
|
1234
1422
|
|
|
1423
|
+
this.recordKill(deletedText, "forward");
|
|
1424
|
+
|
|
1235
1425
|
if (this.onChange) {
|
|
1236
1426
|
this.onChange(this.getText());
|
|
1237
1427
|
}
|
|
@@ -1245,6 +1435,7 @@ export class Editor implements Component {
|
|
|
1245
1435
|
// If at start of line, behave like backspace at column 0 (merge with previous line)
|
|
1246
1436
|
if (this.state.cursorCol === 0) {
|
|
1247
1437
|
if (this.state.cursorLine > 0) {
|
|
1438
|
+
this.recordKill("\n", "backward");
|
|
1248
1439
|
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
|
1249
1440
|
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
|
1250
1441
|
this.state.lines.splice(this.state.cursorLine, 1);
|
|
@@ -1257,9 +1448,39 @@ export class Editor implements Component {
|
|
|
1257
1448
|
const deleteFrom = this.state.cursorCol;
|
|
1258
1449
|
this.state.cursorCol = oldCursorCol;
|
|
1259
1450
|
|
|
1451
|
+
const deletedText = currentLine.slice(deleteFrom, oldCursorCol);
|
|
1260
1452
|
this.state.lines[this.state.cursorLine] =
|
|
1261
1453
|
currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
|
|
1262
1454
|
this.state.cursorCol = deleteFrom;
|
|
1455
|
+
this.recordKill(deletedText, "backward");
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
if (this.onChange) {
|
|
1459
|
+
this.onChange(this.getText());
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
private deleteWordForwards(): void {
|
|
1464
|
+
this.historyIndex = -1; // Exit history browsing mode
|
|
1465
|
+
|
|
1466
|
+
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1467
|
+
|
|
1468
|
+
if (this.state.cursorCol >= currentLine.length) {
|
|
1469
|
+
if (this.state.cursorLine < this.state.lines.length - 1) {
|
|
1470
|
+
this.recordKill("\n", "forward");
|
|
1471
|
+
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
|
1472
|
+
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
|
1473
|
+
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
|
1474
|
+
}
|
|
1475
|
+
} else {
|
|
1476
|
+
const oldCursorCol = this.state.cursorCol;
|
|
1477
|
+
this.moveWordForwards();
|
|
1478
|
+
const deleteTo = this.state.cursorCol;
|
|
1479
|
+
this.state.cursorCol = oldCursorCol;
|
|
1480
|
+
|
|
1481
|
+
const deletedText = currentLine.slice(oldCursorCol, deleteTo);
|
|
1482
|
+
this.state.lines[this.state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo);
|
|
1483
|
+
this.recordKill(deletedText, "forward");
|
|
1263
1484
|
}
|
|
1264
1485
|
|
|
1265
1486
|
if (this.onChange) {
|
|
@@ -1269,6 +1490,7 @@ export class Editor implements Component {
|
|
|
1269
1490
|
|
|
1270
1491
|
private handleForwardDelete(): void {
|
|
1271
1492
|
this.historyIndex = -1; // Exit history browsing mode
|
|
1493
|
+
this.resetKillSequence();
|
|
1272
1494
|
|
|
1273
1495
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
|
1274
1496
|
|
|
@@ -1371,11 +1593,12 @@ export class Editor implements Component {
|
|
|
1371
1593
|
}
|
|
1372
1594
|
|
|
1373
1595
|
private moveCursor(deltaLine: number, deltaCol: number): void {
|
|
1374
|
-
|
|
1596
|
+
this.resetKillSequence();
|
|
1597
|
+
const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX());
|
|
1375
1598
|
|
|
1376
1599
|
if (deltaLine !== 0) {
|
|
1377
1600
|
// Build visual line map for navigation
|
|
1378
|
-
const visualLines = this.buildVisualLineMap(
|
|
1601
|
+
const visualLines = this.buildVisualLineMap(contentWidth);
|
|
1379
1602
|
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
|
1380
1603
|
|
|
1381
1604
|
// Calculate column position within current visual line
|