@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 +3 -3
- package/src/components/editor.ts +69 -15
- package/src/components/select-list.ts +11 -0
- package/src/tui.ts +18 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-tui",
|
|
3
|
-
"version": "12.
|
|
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.
|
|
56
|
-
"@oh-my-pi/pi-utils": "12.
|
|
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",
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
|
760
|
-
if (
|
|
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.#
|
|
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.#
|
|
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
|
-
//
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
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
|
-
|
|
1094
|
-
|
|
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);
|