@caupulican/pi-tui 0.81.42 → 0.84.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.
Files changed (104) hide show
  1. package/dist/autocomplete.d.ts +1 -1
  2. package/dist/autocomplete.d.ts.map +1 -1
  3. package/dist/autocomplete.js +15 -23
  4. package/dist/autocomplete.js.map +1 -1
  5. package/dist/bracketed-paste.d.ts +33 -0
  6. package/dist/bracketed-paste.d.ts.map +1 -0
  7. package/dist/bracketed-paste.js +96 -0
  8. package/dist/bracketed-paste.js.map +1 -0
  9. package/dist/components/box.d.ts.map +1 -1
  10. package/dist/components/box.js +2 -7
  11. package/dist/components/box.js.map +1 -1
  12. package/dist/components/cancellable-loader.d.ts.map +1 -1
  13. package/dist/components/cancellable-loader.js +4 -3
  14. package/dist/components/cancellable-loader.js.map +1 -1
  15. package/dist/components/editor.d.ts +20 -8
  16. package/dist/components/editor.d.ts.map +1 -1
  17. package/dist/components/editor.js +172 -310
  18. package/dist/components/editor.js.map +1 -1
  19. package/dist/components/image.d.ts.map +1 -1
  20. package/dist/components/image.js +12 -35
  21. package/dist/components/image.js.map +1 -1
  22. package/dist/components/input.d.ts +8 -8
  23. package/dist/components/input.d.ts.map +1 -1
  24. package/dist/components/input.js +29 -75
  25. package/dist/components/input.js.map +1 -1
  26. package/dist/components/loader.d.ts.map +1 -1
  27. package/dist/components/loader.js +7 -9
  28. package/dist/components/loader.js.map +1 -1
  29. package/dist/components/markdown.d.ts +3 -11
  30. package/dist/components/markdown.d.ts.map +1 -1
  31. package/dist/components/markdown.js +48 -124
  32. package/dist/components/markdown.js.map +1 -1
  33. package/dist/components/select-list.d.ts.map +1 -1
  34. package/dist/components/select-list.js +4 -9
  35. package/dist/components/select-list.js.map +1 -1
  36. package/dist/components/settings-list.d.ts.map +1 -1
  37. package/dist/components/settings-list.js +4 -12
  38. package/dist/components/settings-list.js.map +1 -1
  39. package/dist/components/spacer.d.ts.map +1 -1
  40. package/dist/components/spacer.js +0 -1
  41. package/dist/components/spacer.js.map +1 -1
  42. package/dist/components/text-layout.d.ts +49 -0
  43. package/dist/components/text-layout.d.ts.map +1 -0
  44. package/dist/components/text-layout.js +103 -0
  45. package/dist/components/text-layout.js.map +1 -0
  46. package/dist/components/text.d.ts +2 -11
  47. package/dist/components/text.d.ts.map +1 -1
  48. package/dist/components/text.js +16 -83
  49. package/dist/components/text.js.map +1 -1
  50. package/dist/components/truncated-text.d.ts.map +1 -1
  51. package/dist/components/truncated-text.js +0 -3
  52. package/dist/components/truncated-text.js.map +1 -1
  53. package/dist/editing-actions.d.ts +17 -0
  54. package/dist/editing-actions.d.ts.map +1 -0
  55. package/dist/editing-actions.js +34 -0
  56. package/dist/editing-actions.js.map +1 -0
  57. package/dist/editor-component.d.ts +9 -0
  58. package/dist/editor-component.d.ts.map +1 -1
  59. package/dist/editor-component.js +4 -1
  60. package/dist/editor-component.js.map +1 -1
  61. package/dist/fuzzy.d.ts.map +1 -1
  62. package/dist/fuzzy.js.map +1 -1
  63. package/dist/index.d.ts +1 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +2 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/keybindings.d.ts +2 -2
  68. package/dist/keybindings.d.ts.map +1 -1
  69. package/dist/keybindings.js +4 -6
  70. package/dist/keybindings.js.map +1 -1
  71. package/dist/keys.d.ts.map +1 -1
  72. package/dist/keys.js.map +1 -1
  73. package/dist/kill-ring.d.ts.map +1 -1
  74. package/dist/kill-ring.js +2 -4
  75. package/dist/kill-ring.js.map +1 -1
  76. package/dist/native-loader.d.ts +3 -0
  77. package/dist/native-loader.d.ts.map +1 -0
  78. package/dist/native-loader.js +25 -0
  79. package/dist/native-loader.js.map +1 -0
  80. package/dist/native-modifiers.d.ts.map +1 -1
  81. package/dist/native-modifiers.js +5 -20
  82. package/dist/native-modifiers.js.map +1 -1
  83. package/dist/stdin-buffer.d.ts +8 -2
  84. package/dist/stdin-buffer.d.ts.map +1 -1
  85. package/dist/stdin-buffer.js +38 -32
  86. package/dist/stdin-buffer.js.map +1 -1
  87. package/dist/terminal-image.d.ts.map +1 -1
  88. package/dist/terminal-image.js.map +1 -1
  89. package/dist/terminal.d.ts +1 -0
  90. package/dist/terminal.d.ts.map +1 -1
  91. package/dist/terminal.js +47 -72
  92. package/dist/terminal.js.map +1 -1
  93. package/dist/tui.d.ts.map +1 -1
  94. package/dist/tui.js +26 -31
  95. package/dist/tui.js.map +1 -1
  96. package/dist/undo-stack.d.ts.map +1 -1
  97. package/dist/undo-stack.js +2 -4
  98. package/dist/undo-stack.js.map +1 -1
  99. package/dist/utils.d.ts.map +1 -1
  100. package/dist/utils.js +197 -275
  101. package/dist/utils.js.map +1 -1
  102. package/dist/word-navigation.d.ts.map +1 -1
  103. package/dist/word-navigation.js.map +1 -1
  104. package/package.json +3 -3
@@ -1,3 +1,6 @@
1
+ import { BracketedPasteBuffer } from "../bracketed-paste.js";
2
+ import { DELETE_CHARACTER_BACKWARD, DELETE_CHARACTER_FORWARD, DELETE_TO_LINE_END, DELETE_TO_LINE_START, DELETE_WORD_BACKWARD, DELETE_WORD_FORWARD, dispatchDeletionInput, } from "../editing-actions.js";
3
+ import { firstNonWhitespaceCharacter } from "../editor-component.js";
1
4
  import { getKeybindings } from "../keybindings.js";
2
5
  import { decodePrintableKey, matchesKey } from "../keys.js";
3
6
  import { KillRing } from "../kill-ring.js";
@@ -183,68 +186,50 @@ function createScrollBorder(direction, hiddenLineCount, width) {
183
186
  return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis;
184
187
  }
185
188
  export class Editor {
186
- state = {
187
- lines: [""],
188
- cursorLine: 0,
189
- cursorCol: 0,
190
- };
191
- /** Focusable interface - set by TUI when focus changes */
192
- focused = false;
193
- tui;
194
- theme;
195
- paddingX = 0;
196
- // Store last render width for cursor navigation
197
- lastWidth = 80;
198
- // Vertical scrolling support
199
- scrollOffset = 0;
200
- // Border color (can be changed dynamically)
201
- borderColor;
202
- // Autocomplete support
203
- autocompleteProvider;
204
- autocompleteList;
205
- autocompleteState = null;
206
- autocompletePrefix = "";
207
- autocompleteMaxVisible = 5;
208
- autocompleteAbort;
209
- autocompleteDebounceTimer;
210
- autocompleteRequestTask = Promise.resolve();
211
- autocompleteStartToken = 0;
212
- autocompleteRequestId = 0;
213
- // Paste tracking for large pastes
214
- pastes = new Map();
215
- pasteCounter = 0;
216
- // Bracketed paste mode buffering
217
- pasteBuffer = "";
218
- isInPaste = false;
219
- // Prompt history for up/down navigation
220
- history = [];
221
- historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
222
- /**
223
- * Consulted on Up arrow in an empty editor, before history navigation.
224
- * Return true after restoring queued message(s) into the editor to consume
225
- * the key; return false to fall through to history.
226
- */
227
- onRecallQueued;
228
- // Kill ring for Emacs-style kill/yank operations
229
- killRing = new KillRing();
230
- lastAction = null;
231
- // Character jump mode
232
- jumpMode = null;
233
- // Preferred visual column for vertical cursor movement (sticky column)
234
- preferredVisualCol = null;
235
- // When the cursor is snapped to the start of an atomic segment, e.g. a
236
- // paste marker, cursorCol no longer reflects where the cursor would have
237
- // landed. This field stores the pre-snap cursorCol so that the next
238
- // vertical move can resolve it to a visual column on whatever VL it belongs
239
- // to.
240
- snappedFromCursorCol = null;
241
- // Undo support
242
- undoStack = new UndoStack();
243
- privateContent;
244
- onSubmit;
245
- onChange;
246
- disableSubmit = false;
247
189
  constructor(tui, theme, options = {}) {
190
+ this.state = {
191
+ lines: [""],
192
+ cursorLine: 0,
193
+ cursorCol: 0,
194
+ };
195
+ /** Focusable interface - set by TUI when focus changes */
196
+ this.focused = false;
197
+ this.paddingX = 0;
198
+ // Store last render width for cursor navigation
199
+ this.lastWidth = 80;
200
+ // Vertical scrolling support
201
+ this.scrollOffset = 0;
202
+ this.autocompleteState = null;
203
+ this.autocompletePrefix = "";
204
+ this.autocompleteMaxVisible = 5;
205
+ this.autocompleteRequestTask = Promise.resolve();
206
+ this.autocompleteStartToken = 0;
207
+ this.autocompleteRequestId = 0;
208
+ // Paste tracking for large pastes
209
+ this.pastes = new Map();
210
+ this.pasteCounter = 0;
211
+ // Bracketed paste mode buffering
212
+ this.bracketedPaste = new BracketedPasteBuffer();
213
+ // Prompt history for up/down navigation
214
+ this.history = [];
215
+ this.historyIndex = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
216
+ // Kill ring for Emacs-style kill/yank operations
217
+ this.killRing = new KillRing();
218
+ this.lastAction = null;
219
+ // Character jump mode
220
+ this.jumpMode = null;
221
+ // Preferred visual column for vertical cursor movement (sticky column)
222
+ this.preferredVisualCol = null;
223
+ // When the cursor is snapped to the start of an atomic segment, e.g. a
224
+ // paste marker, cursorCol no longer reflects where the cursor would have
225
+ // landed. This field stores the pre-snap cursorCol so that the next
226
+ // vertical move can resolve it to a visual column on whatever VL it belongs
227
+ // to.
228
+ this.snappedFromCursorCol = null;
229
+ // Undo support
230
+ this.undoStack = new UndoStack();
231
+ this.supportsChangeSummary = true;
232
+ this.disableSubmit = false;
248
233
  this.tui = tui;
249
234
  this.theme = theme;
250
235
  this.borderColor = theme.borderColor;
@@ -254,6 +239,19 @@ export class Editor {
254
239
  const maxVisible = options.autocompleteMaxVisible ?? 5;
255
240
  this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
256
241
  }
242
+ notifyChange() {
243
+ if (this.onChangeSummary) {
244
+ let firstNonWhitespace;
245
+ for (const line of this.state.lines) {
246
+ firstNonWhitespace = firstNonWhitespaceCharacter(line);
247
+ if (firstNonWhitespace !== undefined)
248
+ break;
249
+ }
250
+ this.onChangeSummary({ firstNonWhitespace });
251
+ }
252
+ if (this.onChange)
253
+ this.onChange(this.getText());
254
+ }
257
255
  /** Set of currently valid paste IDs, for marker-aware segmentation. */
258
256
  validPasteIds() {
259
257
  return new Set(this.pastes.keys());
@@ -360,9 +358,7 @@ export class Editor {
360
358
  this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
361
359
  // Reset scroll - render() will adjust to show cursor
362
360
  this.scrollOffset = 0;
363
- if (this.onChange) {
364
- this.onChange(this.getText());
365
- }
361
+ this.notifyChange();
366
362
  }
367
363
  invalidate() {
368
364
  // No cached state to invalidate currently
@@ -487,30 +483,19 @@ export class Editor {
487
483
  // Control character - cancel and fall through to normal handling
488
484
  this.jumpMode = null;
489
485
  }
490
- // Handle bracketed paste mode
491
- if (data.includes("\x1b[200~")) {
492
- this.isInPaste = true;
493
- this.pasteBuffer = "";
494
- data = data.replace("\x1b[200~", "");
495
- }
496
- if (this.isInPaste) {
497
- this.pasteBuffer += data;
498
- const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
499
- if (endIndex !== -1) {
500
- const pasteContent = this.pasteBuffer.substring(0, endIndex);
501
- if (pasteContent.length > 0) {
502
- this.handlePaste(pasteContent);
503
- }
504
- this.isInPaste = false;
505
- const remaining = this.pasteBuffer.substring(endIndex + 6);
506
- this.pasteBuffer = "";
507
- if (remaining.length > 0) {
508
- this.handleInput(remaining);
509
- }
510
- return;
486
+ const pasteResult = this.bracketedPaste.consume(data);
487
+ if (pasteResult.kind === "pending")
488
+ return;
489
+ if (pasteResult.kind === "complete") {
490
+ if (pasteResult.content.length > 0) {
491
+ this.handlePaste(pasteResult.content);
492
+ }
493
+ if (pasteResult.remainder.length > 0) {
494
+ this.handleInput(pasteResult.remainder);
511
495
  }
512
496
  return;
513
497
  }
498
+ data = pasteResult.data;
514
499
  // Ctrl+C - let parent handle (exit/clear)
515
500
  if (kb.matches(data, "tui.input.copy")) {
516
501
  return;
@@ -532,37 +517,21 @@ export class Editor {
532
517
  return;
533
518
  }
534
519
  if (kb.matches(data, "tui.input.tab")) {
535
- const selected = this.autocompleteList.getSelectedItem();
536
- if (selected && this.autocompleteProvider) {
537
- this.pushUndoSnapshot();
538
- this.lastAction = null;
539
- const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
540
- this.state.lines = result.lines;
541
- this.state.cursorLine = result.cursorLine;
542
- this.setCursorCol(result.cursorCol);
520
+ if (this.applySelectedAutocomplete()) {
543
521
  this.cancelAutocomplete();
544
- if (this.onChange)
545
- this.onChange(this.getText());
522
+ this.notifyChange();
546
523
  }
547
524
  return;
548
525
  }
549
526
  if (kb.matches(data, "tui.select.confirm")) {
550
- const selected = this.autocompleteList.getSelectedItem();
551
- if (selected && this.autocompleteProvider) {
552
- this.pushUndoSnapshot();
553
- this.lastAction = null;
554
- const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
555
- this.state.lines = result.lines;
556
- this.state.cursorLine = result.cursorLine;
557
- this.setCursorCol(result.cursorCol);
527
+ if (this.applySelectedAutocomplete()) {
558
528
  if (this.autocompletePrefix.startsWith("/")) {
559
529
  this.cancelAutocomplete();
560
530
  // Fall through to submit
561
531
  }
562
532
  else {
563
533
  this.cancelAutocomplete();
564
- if (this.onChange)
565
- this.onChange(this.getText());
534
+ this.notifyChange();
566
535
  return;
567
536
  }
568
537
  }
@@ -573,31 +542,8 @@ export class Editor {
573
542
  this.handleTabCompletion();
574
543
  return;
575
544
  }
576
- // Deletion actions
577
- if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
578
- this.deleteToEndOfLine();
579
- return;
580
- }
581
- if (kb.matches(data, "tui.editor.deleteToLineStart")) {
582
- this.deleteToStartOfLine();
583
- return;
584
- }
585
- if (kb.matches(data, "tui.editor.deleteWordBackward")) {
586
- this.deleteWordBackwards();
587
- return;
588
- }
589
- if (kb.matches(data, "tui.editor.deleteWordForward")) {
590
- this.deleteWordForward();
591
- return;
592
- }
593
- if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
594
- this.handleBackspace();
595
- return;
596
- }
597
- if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
598
- this.handleForwardDelete();
545
+ if (dispatchDeletionInput(data, this))
599
546
  return;
600
- }
601
547
  // Kill ring actions
602
548
  if (kb.matches(data, "tui.editor.yank")) {
603
549
  if (!this.privateContent)
@@ -634,7 +580,7 @@ export class Editor {
634
580
  (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
635
581
  (data === "\n" && data.length === 1)) {
636
582
  if (this.shouldSubmitOnBackslashEnter(data, kb)) {
637
- this.handleBackspace();
583
+ this[DELETE_CHARACTER_BACKWARD]();
638
584
  this.submitValue();
639
585
  return;
640
586
  }
@@ -649,7 +595,7 @@ export class Editor {
649
595
  // If char before cursor is \, delete it and insert newline instead of submitting.
650
596
  const currentLine = this.state.lines[this.state.cursorLine] || "";
651
597
  if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
652
- this.handleBackspace();
598
+ this[DELETE_CHARACTER_BACKWARD]();
653
599
  this.addNewLine();
654
600
  return;
655
601
  }
@@ -730,6 +676,18 @@ export class Editor {
730
676
  this.insertCharacter(data);
731
677
  }
732
678
  }
679
+ applySelectedAutocomplete() {
680
+ const selected = this.autocompleteList?.getSelectedItem();
681
+ if (!selected || !this.autocompleteProvider)
682
+ return false;
683
+ this.pushUndoSnapshot();
684
+ this.lastAction = null;
685
+ const result = this.autocompleteProvider.applyCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol, selected, this.autocompletePrefix);
686
+ this.state.lines = result.lines;
687
+ this.state.cursorLine = result.cursorLine;
688
+ this.setCursorCol(result.cursorCol);
689
+ return true;
690
+ }
733
691
  layoutText(contentWidth) {
734
692
  const layoutLines = [];
735
693
  if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
@@ -817,12 +775,7 @@ export class Editor {
817
775
  return this.state.lines.join("\n");
818
776
  }
819
777
  expandPasteMarkers(text) {
820
- let result = text;
821
- for (const [pasteId, pasteContent] of this.pastes) {
822
- const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
823
- result = result.replace(markerRegex, () => pasteContent);
824
- }
825
- return result;
778
+ return text.replace(PASTE_MARKER_REGEX, (marker, idGroup) => this.pastes.get(Number(idGroup)) ?? marker);
826
779
  }
827
780
  /**
828
781
  * Get text with paste markers expanded to their actual content.
@@ -856,8 +809,7 @@ export class Editor {
856
809
  this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
857
810
  this.pastes.clear();
858
811
  this.pasteCounter = 0;
859
- this.pasteBuffer = "";
860
- this.isInPaste = false;
812
+ this.bracketedPaste.clear();
861
813
  this.history = [];
862
814
  this.historyIndex = -1;
863
815
  this.killRing = new KillRing();
@@ -867,8 +819,7 @@ export class Editor {
867
819
  this.preferredVisualCol = null;
868
820
  this.snappedFromCursorCol = null;
869
821
  this.scrollOffset = 0;
870
- if (this.onChange)
871
- this.onChange("");
822
+ this.notifyChange();
872
823
  }
873
824
  /**
874
825
  * Insert text at the current cursor position.
@@ -929,9 +880,7 @@ export class Editor {
929
880
  this.state.cursorLine += insertedLines.length - 1;
930
881
  this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length);
931
882
  }
932
- if (this.onChange) {
933
- this.onChange(this.getText());
934
- }
883
+ this.notifyChange();
935
884
  }
936
885
  // All the editor methods from before...
937
886
  insertCharacter(char, skipUndoCoalescing) {
@@ -952,9 +901,7 @@ export class Editor {
952
901
  const after = line.slice(this.state.cursorCol);
953
902
  this.state.lines[this.state.cursorLine] = before + char + after;
954
903
  this.setCursorCol(this.state.cursorCol + char.length);
955
- if (this.onChange) {
956
- this.onChange(this.getText());
957
- }
904
+ this.notifyChange();
958
905
  // Check if we should trigger or update autocomplete
959
906
  if (!this.autocompleteState) {
960
907
  // Auto-trigger for "/" at the start of a line (slash commands)
@@ -972,22 +919,28 @@ export class Editor {
972
919
  }
973
920
  // Also auto-trigger when typing letters in a slash command or symbol completion context
974
921
  else if (/[a-zA-Z0-9.\-_]/.test(char)) {
975
- const currentLine = this.state.lines[this.state.cursorLine] || "";
976
- const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
977
- // Check if we're in a slash command (with or without space for arguments)
978
- if (this.isInSlashCommandContext(textBeforeCursor)) {
979
- this.tryTriggerAutocomplete();
980
- }
981
- // Check if we're in a symbol-based completion context like @ or #
982
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
983
- this.tryTriggerAutocomplete();
984
- }
922
+ this.triggerAutocompleteForCurrentContext();
985
923
  }
986
924
  }
987
925
  else {
988
926
  this.updateAutocomplete();
989
927
  }
990
928
  }
929
+ triggerAutocompleteForCurrentContext() {
930
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
931
+ const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
932
+ if (this.isInSlashCommandContext(textBeforeCursor) || /(?:^|[\s])[@#][^\s]*$/.test(textBeforeCursor)) {
933
+ this.tryTriggerAutocomplete();
934
+ }
935
+ }
936
+ refreshAutocompleteAfterDeletion() {
937
+ if (this.autocompleteState) {
938
+ this.updateAutocomplete();
939
+ }
940
+ else {
941
+ this.triggerAutocompleteForCurrentContext();
942
+ }
943
+ }
991
944
  handlePaste(pastedText) {
992
945
  this.cancelAutocomplete();
993
946
  this.historyIndex = -1; // Exit history browsing mode
@@ -1009,10 +962,9 @@ export class Editor {
1009
962
  // Clean the pasted text: normalize line endings, expand tabs
1010
963
  const cleanText = this.normalizeText(decodedText);
1011
964
  // Filter out non-printable characters except newlines
1012
- let filteredText = cleanText
1013
- .split("")
1014
- .filter((char) => char === "\n" || (this.privateContent && char === "\t") || char.charCodeAt(0) >= 32)
1015
- .join("");
965
+ let filteredText = this.privateContent
966
+ ? cleanText.replace(/[\x00-\x08\x0b-\x1f]/g, "")
967
+ : cleanText.replace(/[\x00-\x09\x0b-\x1f]/g, "");
1016
968
  // If pasting a file path (starts with /, ~, or .) and the character before
1017
969
  // the cursor is a word character, prepend a space for better readability
1018
970
  if (!this.privateContent && /^[/~.]/.test(filteredText)) {
@@ -1060,9 +1012,7 @@ export class Editor {
1060
1012
  // Move cursor to start of new line
1061
1013
  this.state.cursorLine++;
1062
1014
  this.setCursorCol(0);
1063
- if (this.onChange) {
1064
- this.onChange(this.getText());
1065
- }
1015
+ this.notifyChange();
1066
1016
  }
1067
1017
  shouldSubmitOnBackslashEnter(data, kb) {
1068
1018
  if (this.disableSubmit)
@@ -1084,7 +1034,7 @@ export class Editor {
1084
1034
  if (this.onSubmit)
1085
1035
  this.onSubmit(result);
1086
1036
  }
1087
- handleBackspace() {
1037
+ [DELETE_CHARACTER_BACKWARD]() {
1088
1038
  this.historyIndex = -1; // Exit history browsing mode
1089
1039
  this.lastAction = null;
1090
1040
  if (this.state.cursorCol > 0) {
@@ -1119,34 +1069,10 @@ export class Editor {
1119
1069
  }
1120
1070
  else if (this.state.cursorLine > 0) {
1121
1071
  this.pushUndoSnapshot();
1122
- // Merge with previous line
1123
- const currentLine = this.state.lines[this.state.cursorLine] || "";
1124
- const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
1125
- this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1126
- this.state.lines.splice(this.state.cursorLine, 1);
1127
- this.state.cursorLine--;
1128
- this.setCursorCol(previousLine.length);
1129
- }
1130
- if (this.onChange) {
1131
- this.onChange(this.getText());
1132
- }
1133
- // Update or re-trigger autocomplete after backspace
1134
- if (this.autocompleteState) {
1135
- this.updateAutocomplete();
1136
- }
1137
- else {
1138
- // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
1139
- const currentLine = this.state.lines[this.state.cursorLine] || "";
1140
- const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1141
- // Slash command context
1142
- if (this.isInSlashCommandContext(textBeforeCursor)) {
1143
- this.tryTriggerAutocomplete();
1144
- }
1145
- // Symbol-based completion context like @ or #
1146
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1147
- this.tryTriggerAutocomplete();
1148
- }
1072
+ this.mergeCurrentLineIntoPrevious();
1149
1073
  }
1074
+ this.notifyChange();
1075
+ this.refreshAutocompleteAfterDeletion();
1150
1076
  }
1151
1077
  /**
1152
1078
  * Set cursor column and clear preferredVisualCol.
@@ -1282,7 +1208,33 @@ export class Editor {
1282
1208
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1283
1209
  this.setCursorCol(currentLine.length);
1284
1210
  }
1285
- deleteToStartOfLine() {
1211
+ mergeCurrentLineIntoPrevious() {
1212
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1213
+ const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
1214
+ this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1215
+ this.state.lines.splice(this.state.cursorLine, 1);
1216
+ this.state.cursorLine--;
1217
+ this.setCursorCol(previousLine.length);
1218
+ }
1219
+ mergeNextLineIntoCurrent() {
1220
+ const currentLine = this.state.lines[this.state.cursorLine] || "";
1221
+ const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
1222
+ this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1223
+ this.state.lines.splice(this.state.cursorLine + 1, 1);
1224
+ }
1225
+ isAtCurrentLineEnd(currentLine) {
1226
+ return this.state.cursorCol >= currentLine.length;
1227
+ }
1228
+ hasNextLine() {
1229
+ return this.state.cursorLine < this.state.lines.length - 1;
1230
+ }
1231
+ recordKilledNewline(prepend) {
1232
+ if (this.privateContent)
1233
+ return;
1234
+ this.killRing.push("\n", { prepend, accumulate: this.lastAction === "kill" });
1235
+ this.lastAction = "kill";
1236
+ }
1237
+ [DELETE_TO_LINE_START]() {
1286
1238
  this.historyIndex = -1; // Exit history browsing mode
1287
1239
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1288
1240
  if (this.state.cursorCol > 0) {
@@ -1299,22 +1251,12 @@ export class Editor {
1299
1251
  }
1300
1252
  else if (this.state.cursorLine > 0) {
1301
1253
  this.pushUndoSnapshot();
1302
- // At start of line - merge with previous line, treating newline as deleted text
1303
- if (!this.privateContent) {
1304
- this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
1305
- this.lastAction = "kill";
1306
- }
1307
- const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
1308
- this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1309
- this.state.lines.splice(this.state.cursorLine, 1);
1310
- this.state.cursorLine--;
1311
- this.setCursorCol(previousLine.length);
1312
- }
1313
- if (this.onChange) {
1314
- this.onChange(this.getText());
1254
+ this.recordKilledNewline(true);
1255
+ this.mergeCurrentLineIntoPrevious();
1315
1256
  }
1257
+ this.notifyChange();
1316
1258
  }
1317
- deleteToEndOfLine() {
1259
+ [DELETE_TO_LINE_END]() {
1318
1260
  this.historyIndex = -1; // Exit history browsing mode
1319
1261
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1320
1262
  if (this.state.cursorCol < currentLine.length) {
@@ -1330,36 +1272,20 @@ export class Editor {
1330
1272
  }
1331
1273
  else if (this.state.cursorLine < this.state.lines.length - 1) {
1332
1274
  this.pushUndoSnapshot();
1333
- // At end of line - merge with next line, treating newline as deleted text
1334
- if (!this.privateContent) {
1335
- this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
1336
- this.lastAction = "kill";
1337
- }
1338
- const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
1339
- this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1340
- this.state.lines.splice(this.state.cursorLine + 1, 1);
1341
- }
1342
- if (this.onChange) {
1343
- this.onChange(this.getText());
1275
+ this.recordKilledNewline(false);
1276
+ this.mergeNextLineIntoCurrent();
1344
1277
  }
1278
+ this.notifyChange();
1345
1279
  }
1346
- deleteWordBackwards() {
1280
+ [DELETE_WORD_BACKWARD]() {
1347
1281
  this.historyIndex = -1; // Exit history browsing mode
1348
1282
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1349
1283
  // If at start of line, behave like backspace at column 0 (merge with previous line)
1350
1284
  if (this.state.cursorCol === 0) {
1351
1285
  if (this.state.cursorLine > 0) {
1352
1286
  this.pushUndoSnapshot();
1353
- // Treat newline as deleted text (backward deletion = prepend)
1354
- if (!this.privateContent) {
1355
- this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
1356
- this.lastAction = "kill";
1357
- }
1358
- const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
1359
- this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
1360
- this.state.lines.splice(this.state.cursorLine, 1);
1361
- this.state.cursorLine--;
1362
- this.setCursorCol(previousLine.length);
1287
+ this.recordKilledNewline(true);
1288
+ this.mergeCurrentLineIntoPrevious();
1363
1289
  }
1364
1290
  }
1365
1291
  else {
@@ -1379,25 +1305,17 @@ export class Editor {
1379
1305
  currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
1380
1306
  this.setCursorCol(deleteFrom);
1381
1307
  }
1382
- if (this.onChange) {
1383
- this.onChange(this.getText());
1384
- }
1308
+ this.notifyChange();
1385
1309
  }
1386
- deleteWordForward() {
1310
+ [DELETE_WORD_FORWARD]() {
1387
1311
  this.historyIndex = -1; // Exit history browsing mode
1388
1312
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1389
1313
  // If at end of line, merge with next line (delete the newline)
1390
- if (this.state.cursorCol >= currentLine.length) {
1391
- if (this.state.cursorLine < this.state.lines.length - 1) {
1314
+ if (this.isAtCurrentLineEnd(currentLine)) {
1315
+ if (this.hasNextLine()) {
1392
1316
  this.pushUndoSnapshot();
1393
- // Treat newline as deleted text (forward deletion = append)
1394
- if (!this.privateContent) {
1395
- this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
1396
- this.lastAction = "kill";
1397
- }
1398
- const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
1399
- this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1400
- this.state.lines.splice(this.state.cursorLine + 1, 1);
1317
+ this.recordKilledNewline(false);
1318
+ this.mergeNextLineIntoCurrent();
1401
1319
  }
1402
1320
  }
1403
1321
  else {
@@ -1416,11 +1334,9 @@ export class Editor {
1416
1334
  this.state.lines[this.state.cursorLine] =
1417
1335
  currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo);
1418
1336
  }
1419
- if (this.onChange) {
1420
- this.onChange(this.getText());
1421
- }
1337
+ this.notifyChange();
1422
1338
  }
1423
- handleForwardDelete() {
1339
+ [DELETE_CHARACTER_FORWARD]() {
1424
1340
  this.historyIndex = -1; // Exit history browsing mode
1425
1341
  this.lastAction = null;
1426
1342
  const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1438,30 +1354,10 @@ export class Editor {
1438
1354
  }
1439
1355
  else if (this.state.cursorLine < this.state.lines.length - 1) {
1440
1356
  this.pushUndoSnapshot();
1441
- // At end of line - merge with next line
1442
- const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
1443
- this.state.lines[this.state.cursorLine] = currentLine + nextLine;
1444
- this.state.lines.splice(this.state.cursorLine + 1, 1);
1445
- }
1446
- if (this.onChange) {
1447
- this.onChange(this.getText());
1448
- }
1449
- // Update or re-trigger autocomplete after forward delete
1450
- if (this.autocompleteState) {
1451
- this.updateAutocomplete();
1452
- }
1453
- else {
1454
- const currentLine = this.state.lines[this.state.cursorLine] || "";
1455
- const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
1456
- // Slash command context
1457
- if (this.isInSlashCommandContext(textBeforeCursor)) {
1458
- this.tryTriggerAutocomplete();
1459
- }
1460
- // Symbol-based completion context like @ or #
1461
- else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
1462
- this.tryTriggerAutocomplete();
1463
- }
1357
+ this.mergeNextLineIntoCurrent();
1464
1358
  }
1359
+ this.notifyChange();
1360
+ this.refreshAutocompleteAfterDeletion();
1465
1361
  }
1466
1362
  /**
1467
1363
  * Build a mapping from visual lines to logical positions.
@@ -1634,36 +1530,7 @@ export class Editor {
1634
1530
  */
1635
1531
  insertYankedText(text) {
1636
1532
  this.historyIndex = -1; // Exit history browsing mode
1637
- const lines = text.split("\n");
1638
- if (lines.length === 1) {
1639
- // Single line - insert at cursor
1640
- const currentLine = this.state.lines[this.state.cursorLine] || "";
1641
- const before = currentLine.slice(0, this.state.cursorCol);
1642
- const after = currentLine.slice(this.state.cursorCol);
1643
- this.state.lines[this.state.cursorLine] = before + text + after;
1644
- this.setCursorCol(this.state.cursorCol + text.length);
1645
- }
1646
- else {
1647
- // Multi-line insert
1648
- const currentLine = this.state.lines[this.state.cursorLine] || "";
1649
- const before = currentLine.slice(0, this.state.cursorCol);
1650
- const after = currentLine.slice(this.state.cursorCol);
1651
- // First line merges with text before cursor
1652
- this.state.lines[this.state.cursorLine] = before + (lines[0] || "");
1653
- // Insert middle lines
1654
- for (let i = 1; i < lines.length - 1; i++) {
1655
- this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || "");
1656
- }
1657
- // Last line merges with text after cursor
1658
- const lastLineIndex = this.state.cursorLine + lines.length - 1;
1659
- this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || "") + after);
1660
- // Update cursor position
1661
- this.state.cursorLine = lastLineIndex;
1662
- this.setCursorCol((lines[lines.length - 1] || "").length);
1663
- }
1664
- if (this.onChange) {
1665
- this.onChange(this.getText());
1666
- }
1533
+ this.insertTextAtCursorInternal(text);
1667
1534
  }
1668
1535
  /**
1669
1536
  * Delete the previously yanked text (used by yank-pop).
@@ -1697,9 +1564,7 @@ export class Editor {
1697
1564
  this.state.cursorLine = startLine;
1698
1565
  this.setCursorCol(startCol);
1699
1566
  }
1700
- if (this.onChange) {
1701
- this.onChange(this.getText());
1702
- }
1567
+ this.notifyChange();
1703
1568
  }
1704
1569
  pushUndoSnapshot() {
1705
1570
  if (this.privateContent)
@@ -1716,9 +1581,7 @@ export class Editor {
1716
1581
  this.pasteCounter = snapshot.pasteCounter;
1717
1582
  this.lastAction = null;
1718
1583
  this.preferredVisualCol = null;
1719
- if (this.onChange) {
1720
- this.onChange(this.getText());
1721
- }
1584
+ this.notifyChange();
1722
1585
  }
1723
1586
  /**
1724
1587
  * Jump to the first occurrence of a character in the specified direction.
@@ -1752,8 +1615,8 @@ export class Editor {
1752
1615
  this.lastAction = null;
1753
1616
  const currentLine = this.state.lines[this.state.cursorLine] || "";
1754
1617
  // If at end of line, move to start of next line
1755
- if (this.state.cursorCol >= currentLine.length) {
1756
- if (this.state.cursorLine < this.state.lines.length - 1) {
1618
+ if (this.isAtCurrentLineEnd(currentLine)) {
1619
+ if (this.hasNextLine()) {
1757
1620
  this.state.cursorLine++;
1758
1621
  this.setCursorCol(0);
1759
1622
  }
@@ -1900,8 +1763,7 @@ export class Editor {
1900
1763
  this.state.lines = result.lines;
1901
1764
  this.state.cursorLine = result.cursorLine;
1902
1765
  this.setCursorCol(result.cursorCol);
1903
- if (this.onChange)
1904
- this.onChange(this.getText());
1766
+ this.notifyChange();
1905
1767
  this.tui.requestRender();
1906
1768
  return;
1907
1769
  }