@oh-my-pi/pi-tui 5.5.0 → 5.6.7

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.
@@ -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 { Component } from "../tui";
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 visualLines = this.buildVisualLineMap(this.lastWidth);
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 visualLines = this.buildVisualLineMap(this.lastWidth);
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 topLeft = this.borderColor(`${box.topLeft}${box.horizontal}`);
401
- const topRight = this.borderColor(`${box.horizontal}${box.topRight}`);
402
- const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}`);
403
- const bottomRight = this.borderColor(`${box.horizontal}${box.bottomRight}`);
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 - content area is width minus 6 for borders (3 left + 3 right)
407
- const contentAreaWidth = width - 6;
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
- // Reserve: 2 for "╭─", 2 for "─╮" = 4 total for corners
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
- // Content area is width - 6 (for "│ " prefix and " │" suffix borders)
434
- const lineContentWidth = width - 6;
435
- for (const layoutLine of layoutLines) {
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 6-char borders (3 left + 3 right)
479
- const isLastLine = layoutLine === layoutLines[layoutLines.length - 1];
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
- // Last line: "╰─ " (3) + content + padding + " ─╯" (3) = 6 chars border
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(` ${box.vertical}`);
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 contentWidth = width - 6;
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 = 3 + Math.max(0, lineWidth - lastWidth);
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 = 3 + visibleWidth(before);
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 (isCtrlC(data)) {
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 (isEscape(data)) {
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 (isArrowUp(data) || isArrowDown(data) || isEnter(data) || isTab(data)) {
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 (isArrowUp(data) || isArrowDown(data)) {
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 (isTab(data)) {
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 (isEnter(data) && this.autocompletePrefix.startsWith("/")) {
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 (isEnter(data)) {
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 (isTab(data) && !this.isAutocompleting) {
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 (isCtrlK(data)) {
779
+ if (matchesKey(data, "ctrl+k")) {
694
780
  this.deleteToEndOfLine();
695
781
  }
696
782
  // Ctrl+U - Delete to start of line
697
- else if (isCtrlU(data)) {
783
+ else if (matchesKey(data, "ctrl+u")) {
698
784
  this.deleteToStartOfLine();
699
785
  }
700
786
  // Ctrl+W - Delete word backwards
701
- else if (isCtrlW(data)) {
787
+ else if (matchesKey(data, "ctrl+w")) {
702
788
  this.deleteWordBackwards();
703
789
  }
704
790
  // Option/Alt+Backspace - Delete word backwards
705
- else if (isAltBackspace(data)) {
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 (isCtrlA(data)) {
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 (isCtrlE(data)) {
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 (isAltEnter(data)) {
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
- isShiftEnter(data) || // Shift+Enter (Kitty protocol, handles lock bits)
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 (isEnter(data)) {
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 (isBackspace(data) || isShiftBackspace(data)) {
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 (isHome(data)) {
876
+ else if (matchesKey(data, "home")) {
781
877
  this.moveToLineStart();
782
- } else if (isEnd(data)) {
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 (isDelete(data) || isShiftDelete(data)) {
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 (isAltLeft(data) || isCtrlLeft(data)) {
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 (isAltRight(data) || isCtrlRight(data)) {
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 (isArrowUp(data)) {
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 (isArrowDown(data)) {
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 (isArrowRight(data)) {
912
+ } else if (matchesKey(data, "right")) {
815
913
  // Right
816
914
  this.moveCursor(0, 1);
817
- } else if (isArrowLeft(data)) {
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 (isShiftSpace(data)) {
920
+ else if (matchesKey(data, "shift+space")) {
823
921
  this.insertCharacter(" ");
824
922
  }
825
- // Regular characters (printable characters and unicode, but not control characters)
826
- else if (data.charCodeAt(0) >= 32) {
827
- this.insertCharacter(data);
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
- const width = this.lastWidth;
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(width);
1601
+ const visualLines = this.buildVisualLineMap(contentWidth);
1379
1602
  const currentVisualLine = this.findCurrentVisualLine(visualLines);
1380
1603
 
1381
1604
  // Calculate column position within current visual line