@oh-my-pi/pi-tui 12.11.2 → 12.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-tui",
3
- "version": "12.11.2",
3
+ "version": "12.12.1",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -52,8 +52,8 @@
52
52
  "bun": ">=1.3.7"
53
53
  },
54
54
  "dependencies": {
55
- "@oh-my-pi/pi-natives": "12.11.2",
56
- "@oh-my-pi/pi-utils": "12.11.2",
55
+ "@oh-my-pi/pi-natives": "12.12.1",
56
+ "@oh-my-pi/pi-utils": "12.12.1",
57
57
  "@types/mime-types": "^3.0.1",
58
58
  "chalk": "^5.6.2",
59
59
  "marked": "^17.0.2",
@@ -246,6 +246,7 @@ function decodeKittyPrintable(data: string): string | undefined {
246
246
  return undefined;
247
247
  }
248
248
  }
249
+ const DEFAULT_PAGE_SCROLL_LINES = 10;
249
250
 
250
251
  interface EditorState {
251
252
  lines: string[];
@@ -284,6 +285,8 @@ interface HistoryStorage {
284
285
  getRecent(limit: number): HistoryEntry[];
285
286
  }
286
287
 
288
+ type HistoryCursorAnchor = "start" | "end";
289
+
287
290
  export class Editor implements Component, Focusable {
288
291
  #state: EditorState = {
289
292
  lines: [""],
@@ -388,8 +391,9 @@ export class Editor implements Component, Focusable {
388
391
  }
389
392
 
390
393
  setMaxHeight(maxHeight: number | undefined): void {
394
+ if (this.#maxHeight === maxHeight) return;
391
395
  this.#maxHeight = maxHeight;
392
- this.#scrollOffset = 0;
396
+ // Don't reset scrollOffset #updateScrollOffset will clamp it on next render
393
397
  }
394
398
 
395
399
  setPaddingX(paddingX: number): void {
@@ -451,28 +455,29 @@ export class Editor implements Component, Focusable {
451
455
  #navigateHistory(direction: 1 | -1): void {
452
456
  this.#resetKillSequence();
453
457
  if (this.#history.length === 0) return;
454
-
455
458
  const newIndex = this.#historyIndex - direction; // Up(-1) increases index, Down(1) decreases
456
459
  if (newIndex < -1 || newIndex >= this.#history.length) return;
457
-
458
460
  this.#historyIndex = newIndex;
459
-
460
461
  if (this.#historyIndex === -1) {
461
462
  // Returned to "current" state - clear editor
462
- this.#setTextInternal("");
463
+ this.#setTextInternal("", "end");
463
464
  } else {
464
- this.#setTextInternal(this.#history[this.#historyIndex] || "");
465
+ const cursorAnchor: HistoryCursorAnchor = direction === -1 ? "start" : "end";
466
+ this.#setTextInternal(this.#history[this.#historyIndex] || "", cursorAnchor);
465
467
  }
466
468
  }
467
-
468
469
  /** Internal setText that doesn't reset history state - used by navigateHistory */
469
- #setTextInternal(text: string): void {
470
+ #setTextInternal(text: string, cursorAnchor: HistoryCursorAnchor = "end"): void {
470
471
  this.#undoStack.length = 0;
471
472
  const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
472
473
  this.#state.lines = lines.length === 0 ? [""] : lines;
473
- this.#state.cursorLine = this.#state.lines.length - 1;
474
- this.#setCursorCol(this.#state.lines[this.#state.cursorLine]?.length || 0);
475
-
474
+ if (cursorAnchor === "start") {
475
+ this.#state.cursorLine = 0;
476
+ this.#setCursorCol(0);
477
+ } else {
478
+ this.#state.cursorLine = this.#state.lines.length - 1;
479
+ this.#setCursorCol(this.#state.lines[this.#state.cursorLine]?.length || 0);
480
+ }
476
481
  if (this.onChange) {
477
482
  this.onChange(this.getText());
478
483
  }
@@ -501,6 +506,12 @@ export class Editor implements Component, Focusable {
501
506
  return Math.max(1, this.#maxHeight - 2);
502
507
  }
503
508
 
509
+ #getPageScrollStep(totalVisualLines: number): number {
510
+ const visibleHeight =
511
+ this.#maxHeight === undefined ? DEFAULT_PAGE_SCROLL_LINES : this.#getVisibleContentHeight(totalVisualLines);
512
+ return Math.max(1, visibleHeight - 1);
513
+ }
514
+
504
515
  #updateScrollOffset(layoutWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void {
505
516
  if (layoutLines.length <= visibleHeight) {
506
517
  this.#scrollOffset = 0;
@@ -751,13 +762,20 @@ export class Editor implements Component, Focusable {
751
762
  else if (
752
763
  matchesKey(data, "up") ||
753
764
  matchesKey(data, "down") ||
765
+ matchesKey(data, "pageUp") ||
766
+ matchesKey(data, "pageDown") ||
754
767
  matchesKey(data, "enter") ||
755
768
  matchesKey(data, "return") ||
756
769
  data === "\n" ||
757
770
  matchesKey(data, "tab")
758
771
  ) {
759
- // Only pass arrow keys to the list, not Enter/Tab (we handle those directly)
760
- if (matchesKey(data, "up") || matchesKey(data, "down")) {
772
+ // Only pass navigation keys to the list, not Enter/Tab (we handle those directly)
773
+ if (
774
+ matchesKey(data, "up") ||
775
+ matchesKey(data, "down") ||
776
+ matchesKey(data, "pageUp") ||
777
+ matchesKey(data, "pageDown")
778
+ ) {
761
779
  this.#autocompleteList.handleInput(data);
762
780
  this.onAutocompleteUpdate?.();
763
781
  return;
@@ -935,6 +953,22 @@ export class Editor implements Component, Focusable {
935
953
  } else if (matchesKey(data, "end")) {
936
954
  this.#moveToLineEnd();
937
955
  }
956
+ // Page navigation (PageUp/PageDown)
957
+ else if (matchesKey(data, "pageUp")) {
958
+ if (this.#isEditorEmpty()) {
959
+ this.#navigateHistory(-1);
960
+ } else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) {
961
+ this.#navigateHistory(-1);
962
+ } else {
963
+ this.#pageScroll(-1);
964
+ }
965
+ } else if (matchesKey(data, "pageDown")) {
966
+ if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
967
+ this.#navigateHistory(1);
968
+ } else {
969
+ this.#pageScroll(1);
970
+ }
971
+ }
938
972
  // Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
939
973
  else if (matchesKey(data, "delete") || matchesKey(data, "shift+delete")) {
940
974
  this.#handleForwardDelete();
@@ -1122,9 +1156,19 @@ export class Editor implements Component, Focusable {
1122
1156
  this.#setTextInternal(text);
1123
1157
  }
1124
1158
 
1159
+ #exitHistoryForEditing(): void {
1160
+ if (this.#historyIndex === -1) return;
1161
+ if (this.#state.cursorLine === 0 && this.#state.cursorCol === 0) {
1162
+ this.#state.cursorLine = this.#state.lines.length - 1;
1163
+ const line = this.#state.lines[this.#state.cursorLine] || "";
1164
+ this.#setCursorCol(line.length);
1165
+ }
1166
+ this.#historyIndex = -1;
1167
+ }
1168
+
1125
1169
  /** Insert text at the current cursor position */
1126
1170
  insertText(text: string): void {
1127
- this.#historyIndex = -1;
1171
+ this.#exitHistoryForEditing();
1128
1172
  this.#resetKillSequence();
1129
1173
  this.#recordUndoState();
1130
1174
 
@@ -1142,7 +1186,7 @@ export class Editor implements Component, Focusable {
1142
1186
 
1143
1187
  // All the editor methods from before...
1144
1188
  #insertCharacter(char: string): void {
1145
- this.#historyIndex = -1; // Exit history browsing mode
1189
+ this.#exitHistoryForEditing();
1146
1190
  this.#resetKillSequence();
1147
1191
  this.#recordUndoState();
1148
1192
 
@@ -1896,6 +1940,16 @@ export class Editor implements Component, Focusable {
1896
1940
  }
1897
1941
  }
1898
1942
 
1943
+ #pageScroll(direction: -1 | 1): void {
1944
+ this.#resetKillSequence();
1945
+ const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
1946
+ const currentVisualLine = this.#findCurrentVisualLine(visualLines);
1947
+ const step = this.#getPageScrollStep(visualLines.length);
1948
+ const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * step));
1949
+ if (targetVisualLine === currentVisualLine) return;
1950
+ this.#moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
1951
+ }
1952
+
1899
1953
  #moveWordBackwards(): void {
1900
1954
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1901
1955
 
@@ -148,6 +148,7 @@ export class SelectList implements Component {
148
148
  }
149
149
 
150
150
  handleInput(keyData: string): void {
151
+ if (this.#filteredItems.length === 0) return;
151
152
  // Up arrow - wrap to bottom when at top
152
153
  if (matchesKey(keyData, "up")) {
153
154
  this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
@@ -158,6 +159,16 @@ export class SelectList implements Component {
158
159
  this.#selectedIndex = this.#selectedIndex === this.#filteredItems.length - 1 ? 0 : this.#selectedIndex + 1;
159
160
  this.#notifySelectionChange();
160
161
  }
162
+ // PageUp - jump up by one visible page
163
+ else if (matchesKey(keyData, "pageUp")) {
164
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisible);
165
+ this.#notifySelectionChange();
166
+ }
167
+ // PageDown - jump down by one visible page
168
+ else if (matchesKey(keyData, "pageDown")) {
169
+ this.#selectedIndex = Math.min(this.#filteredItems.length - 1, this.#selectedIndex + this.maxVisible);
170
+ this.#notifySelectionChange();
171
+ }
161
172
  // Enter
162
173
  else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
163
174
  const selectedItem = this.#filteredItems[this.#selectedIndex];
package/src/tui.ts CHANGED
@@ -967,28 +967,24 @@ export class TUI extends Container {
967
967
 
968
968
  // All changes are in deleted lines (nothing to render, just clear)
969
969
  if (firstChanged >= newLines.length) {
970
+ const extraLines = this.#previousLines.length - newLines.length;
971
+ if (extraLines > height) {
972
+ logRedraw(`deletedLines > height (${extraLines} > ${height})`);
973
+ fullRender(true);
974
+ return;
975
+ }
970
976
  const targetRow = Math.max(0, newLines.length - 1);
971
977
  let buffer = "\x1b[?2026h";
972
978
  const lineDiff = computeLineDiff(targetRow);
973
979
  if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
974
980
  else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
975
981
  buffer += "\r";
976
- // Clear extra lines without scrolling
977
- const extraLines = this.#previousLines.length - newLines.length;
978
- if (extraLines > height) {
979
- logRedraw(`extraLines > height (${extraLines} > ${height})`);
980
- fullRender(true);
981
- return;
982
- }
983
- if (extraLines > 0) {
984
- buffer += "\x1b[1B";
985
- }
986
- for (let i = 0; i < extraLines; i++) {
987
- buffer += "\r\x1b[2K";
988
- if (i < extraLines - 1) buffer += "\x1b[1B";
989
- }
990
- if (extraLines > 0) {
991
- buffer += `\x1b[${extraLines}A`;
982
+ // Erase all stale lines below the new content
983
+ if (newLines.length > 0) {
984
+ buffer += "\x1b[1B\x1b[J\x1b[1A";
985
+ } else {
986
+ // Content is completely empty — clear from cursor row
987
+ buffer += "\x1b[J";
992
988
  }
993
989
  const cursorUpdate = this.#buildHardwareCursorSequence(cursorPos, newLines.length, targetRow);
994
990
  buffer += cursorUpdate.sequence;
@@ -1082,7 +1078,10 @@ export class TUI extends Container {
1082
1078
  // Track where cursor ended up after rendering
1083
1079
  let finalCursorRow = renderEnd;
1084
1080
 
1085
- // If we had more lines before, clear them and move cursor back
1081
+ // If we had more lines before, clear everything below new content.
1082
+ // Uses \x1b[J (erase-below) to atomically clear all stale rows in one
1083
+ // operation instead of clearing line-by-line. This avoids cursor-tracking
1084
+ // drift that can cause stale content to remain visible.
1086
1085
  if (this.#previousLines.length > newLines.length) {
1087
1086
  // Move to end of new content first if we stopped before it
1088
1087
  if (renderEnd < newLines.length - 1) {
@@ -1090,12 +1089,8 @@ export class TUI extends Container {
1090
1089
  buffer += `\x1b[${moveDown}B`;
1091
1090
  finalCursorRow = newLines.length - 1;
1092
1091
  }
1093
- const extraLines = this.#previousLines.length - newLines.length;
1094
- for (let i = newLines.length; i < this.#previousLines.length; i++) {
1095
- buffer += "\r\n\x1b[2K";
1096
- }
1097
- // Move cursor back to end of new content
1098
- buffer += `\x1b[${extraLines}A`;
1092
+ // Move to the first stale line and erase from there to end of screen
1093
+ buffer += "\r\n\x1b[J\x1b[A";
1099
1094
  }
1100
1095
 
1101
1096
  const cursorUpdate = this.#buildHardwareCursorSequence(cursorPos, newLines.length, finalCursorRow);