@oh-my-pi/pi-tui 13.5.2 → 13.5.3
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/CHANGELOG.md +8 -0
- package/package.json +3 -3
- package/src/tui.ts +50 -70
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [13.5.3] - 2026-03-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed append rendering logic to correctly handle offscreen header changes during content overflow growth, preserving scroll history integrity
|
|
10
|
+
- Fixed visible tail line updates when appending new content during viewport overflow conditions
|
|
11
|
+
- Fixed cursor positioning instability when appending content under external cursor relocation by using absolute screen addressing instead of relative cursor movement
|
|
12
|
+
|
|
5
13
|
## [13.5.2] - 2026-03-01
|
|
6
14
|
### Breaking Changes
|
|
7
15
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "13.5.
|
|
4
|
+
"version": "13.5.3",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"test": "bun test test/*.test.ts"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@oh-my-pi/pi-natives": "13.5.
|
|
37
|
-
"@oh-my-pi/pi-utils": "13.5.
|
|
36
|
+
"@oh-my-pi/pi-natives": "13.5.3",
|
|
37
|
+
"@oh-my-pi/pi-utils": "13.5.3",
|
|
38
38
|
"marked": "^17.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
package/src/tui.ts
CHANGED
|
@@ -856,6 +856,12 @@ export class TUI extends Container {
|
|
|
856
856
|
return null;
|
|
857
857
|
}
|
|
858
858
|
|
|
859
|
+
#moveToScreenPosition(row: number, col = 0): string {
|
|
860
|
+
const safeRow = Math.max(0, row);
|
|
861
|
+
const safeCol = Math.max(0, col);
|
|
862
|
+
return `\x1b[${safeRow + 1};${safeCol + 1}H`;
|
|
863
|
+
}
|
|
864
|
+
|
|
859
865
|
#doRender(): void {
|
|
860
866
|
if (this.#stopped) return;
|
|
861
867
|
const width = this.terminal.columns;
|
|
@@ -928,7 +934,7 @@ export class TUI extends Container {
|
|
|
928
934
|
// === write only the visible viewport lines. ===
|
|
929
935
|
// Used for height changes, content shrink, and all diff fallback paths.
|
|
930
936
|
// Key properties:
|
|
931
|
-
// -
|
|
937
|
+
// - Uses absolute screen addressing for stable cursor anchoring
|
|
932
938
|
// - Never uses \x1b[3J — preserves scrollback history
|
|
933
939
|
// - Writes only viewport-visible lines — no intermediate states
|
|
934
940
|
const viewportRepaint = (): void => {
|
|
@@ -938,11 +944,8 @@ export class TUI extends Container {
|
|
|
938
944
|
|
|
939
945
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
940
946
|
|
|
941
|
-
// Move cursor
|
|
942
|
-
|
|
943
|
-
buffer += `\x1b[${hardwareCursorRow}A`;
|
|
944
|
-
}
|
|
945
|
-
buffer += "\r"; // Column 0
|
|
947
|
+
// Move cursor to top-left of the viewport using absolute addressing.
|
|
948
|
+
buffer += this.#moveToScreenPosition(0, 0);
|
|
946
949
|
|
|
947
950
|
// Clear from here downward — erases old content and any stale rows below.
|
|
948
951
|
// Does NOT touch scrollback above us.
|
|
@@ -1034,14 +1037,28 @@ export class TUI extends Container {
|
|
|
1034
1037
|
lastChanged = i;
|
|
1035
1038
|
}
|
|
1036
1039
|
}
|
|
1037
|
-
const
|
|
1040
|
+
const previousLineCount = this.#previousLines.length;
|
|
1041
|
+
const appendedLines = newLines.length > previousLineCount;
|
|
1038
1042
|
if (appendedLines) {
|
|
1039
1043
|
if (firstChanged === -1) {
|
|
1040
|
-
firstChanged =
|
|
1044
|
+
firstChanged = previousLineCount;
|
|
1041
1045
|
}
|
|
1042
1046
|
lastChanged = newLines.length - 1;
|
|
1043
1047
|
}
|
|
1044
|
-
|
|
1048
|
+
|
|
1049
|
+
const pureAppendStart = appendedLines && firstChanged === previousLineCount && firstChanged > 0;
|
|
1050
|
+
let canAppendWithOffscreenChanges = false;
|
|
1051
|
+
if (appendedLines && firstChanged >= 0 && firstChanged < previousViewportTop && previousViewportTop > 0) {
|
|
1052
|
+
canAppendWithOffscreenChanges = true;
|
|
1053
|
+
for (let i = previousViewportTop; i < previousLineCount; i++) {
|
|
1054
|
+
if (this.#previousLines[i] !== newLines[i]) {
|
|
1055
|
+
canAppendWithOffscreenChanges = false;
|
|
1056
|
+
break;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const appendCandidate = pureAppendStart || canAppendWithOffscreenChanges;
|
|
1061
|
+
const appendFrom = appendCandidate && !pureAppendStart ? previousLineCount : firstChanged;
|
|
1045
1062
|
|
|
1046
1063
|
// No changes - but still need to update hardware cursor position if it moved
|
|
1047
1064
|
if (firstChanged === -1) {
|
|
@@ -1051,10 +1068,10 @@ export class TUI extends Container {
|
|
|
1051
1068
|
}
|
|
1052
1069
|
const renderEnd = Math.min(lastChanged, newLines.length - 1);
|
|
1053
1070
|
const viewportShifted = viewportTop !== previousViewportTop;
|
|
1054
|
-
const
|
|
1055
|
-
if (viewportShifted && !
|
|
1071
|
+
const appendScroll = appendCandidate && renderEnd >= appendFrom && renderEnd > previousViewportBottom;
|
|
1072
|
+
if (viewportShifted && !appendScroll) {
|
|
1056
1073
|
logRedraw(
|
|
1057
|
-
`viewport shift fallback (prevTop=${previousViewportTop}, top=${viewportTop}, first=${firstChanged}, end=${renderEnd},
|
|
1074
|
+
`viewport shift fallback (prevTop=${previousViewportTop}, top=${viewportTop}, first=${firstChanged}, end=${renderEnd}, appendScroll=${appendScroll}, appendFrom=${appendFrom})`,
|
|
1058
1075
|
);
|
|
1059
1076
|
viewportRepaint();
|
|
1060
1077
|
return;
|
|
@@ -1076,15 +1093,7 @@ export class TUI extends Container {
|
|
|
1076
1093
|
return;
|
|
1077
1094
|
}
|
|
1078
1095
|
let buffer = "\x1b[?2026h";
|
|
1079
|
-
|
|
1080
|
-
if (!Number.isFinite(lineDiff) || Math.abs(lineDiff) > height * 2) {
|
|
1081
|
-
logRedraw(`large deleted-line delta (${lineDiff})`);
|
|
1082
|
-
viewportRepaint();
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
|
|
1086
|
-
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
|
|
1087
|
-
buffer += "\r";
|
|
1096
|
+
buffer += this.#moveToScreenPosition(targetScreenRow, 0);
|
|
1088
1097
|
// Erase stale rows below the new tail without scrolling.
|
|
1089
1098
|
if (newLines.length > 0) {
|
|
1090
1099
|
if (targetScreenRow < height - 1) {
|
|
@@ -1114,7 +1123,7 @@ export class TUI extends Container {
|
|
|
1114
1123
|
// Check if firstChanged is above what was previously visible
|
|
1115
1124
|
// Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
|
|
1116
1125
|
const previousContentViewportTop = previousViewportTop;
|
|
1117
|
-
if (firstChanged < previousContentViewportTop) {
|
|
1126
|
+
if (!appendScroll && firstChanged < previousContentViewportTop) {
|
|
1118
1127
|
// First change is above previous viewport - force a viewport-anchored full re-render.
|
|
1119
1128
|
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`);
|
|
1120
1129
|
viewportRepaint();
|
|
@@ -1124,20 +1133,12 @@ export class TUI extends Container {
|
|
|
1124
1133
|
// Render from first changed line to end
|
|
1125
1134
|
// Build buffer with all updates wrapped in synchronized output
|
|
1126
1135
|
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
|
1127
|
-
const moveTargetRow =
|
|
1136
|
+
const moveTargetRow = appendScroll ? appendFrom - 1 : firstChanged;
|
|
1128
1137
|
const moveTargetScreenRow = moveTargetRow - previousViewportTop;
|
|
1129
|
-
if (
|
|
1138
|
+
if (appendScroll) {
|
|
1130
1139
|
let appendBuffer = "\x1b[?2026h";
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
logRedraw(`append fallback due to large delta (${appendLineDiff})`);
|
|
1134
|
-
viewportRepaint();
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1137
|
-
if (appendLineDiff > 0) appendBuffer += `\x1b[${appendLineDiff}B`;
|
|
1138
|
-
else if (appendLineDiff < 0) appendBuffer += `\x1b[${-appendLineDiff}A`;
|
|
1139
|
-
appendBuffer += "\r";
|
|
1140
|
-
for (let i = firstChanged; i <= renderEnd; i++) {
|
|
1140
|
+
appendBuffer += this.#moveToScreenPosition(moveTargetScreenRow, 0);
|
|
1141
|
+
for (let i = appendFrom; i <= renderEnd; i++) {
|
|
1141
1142
|
appendBuffer += "\r\n\x1b[2K";
|
|
1142
1143
|
const line = newLines[i];
|
|
1143
1144
|
const isImage = TERMINAL.isImageLine(line);
|
|
@@ -1148,7 +1149,7 @@ export class TUI extends Container {
|
|
|
1148
1149
|
}
|
|
1149
1150
|
appendBuffer += line;
|
|
1150
1151
|
}
|
|
1151
|
-
const appendEndScreenRow = Math.min(height - 1, moveTargetScreenRow + (renderEnd -
|
|
1152
|
+
const appendEndScreenRow = Math.min(height - 1, moveTargetScreenRow + (renderEnd - appendFrom + 1));
|
|
1152
1153
|
const cursorPosScreen = cursorPos
|
|
1153
1154
|
? { row: Math.max(0, cursorPos.row - viewportTop), col: cursorPos.col }
|
|
1154
1155
|
: null;
|
|
@@ -1169,7 +1170,7 @@ export class TUI extends Container {
|
|
|
1169
1170
|
if (
|
|
1170
1171
|
moveTargetScreenRow < 0 ||
|
|
1171
1172
|
moveTargetScreenRow >= height ||
|
|
1172
|
-
(!
|
|
1173
|
+
(!appendScroll && renderEnd > previousViewportBottom)
|
|
1173
1174
|
) {
|
|
1174
1175
|
logRedraw(
|
|
1175
1176
|
`offscreen diff fallback (move=${moveTargetScreenRow}, renderEnd=${renderEnd}, viewportBottom=${previousViewportBottom})`,
|
|
@@ -1178,20 +1179,10 @@ export class TUI extends Container {
|
|
|
1178
1179
|
return;
|
|
1179
1180
|
}
|
|
1180
1181
|
|
|
1181
|
-
// Move cursor to first changed line (screen-relative)
|
|
1182
|
+
// Move cursor to first changed line (screen-relative) using absolute coordinates.
|
|
1182
1183
|
const lineDiff = moveTargetScreenRow - hardwareCursorRow;
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
viewportRepaint();
|
|
1186
|
-
return;
|
|
1187
|
-
}
|
|
1188
|
-
if (lineDiff > 0) {
|
|
1189
|
-
buffer += `\x1b[${lineDiff}B`; // Move down
|
|
1190
|
-
} else if (lineDiff < 0) {
|
|
1191
|
-
buffer += `\x1b[${-lineDiff}A`; // Move up
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
|
|
1184
|
+
buffer += this.#moveToScreenPosition(moveTargetScreenRow, 0);
|
|
1185
|
+
if (appendScroll) buffer += "\r\n";
|
|
1195
1186
|
|
|
1196
1187
|
// Only render changed lines (firstChanged to lastChanged), not all lines to end
|
|
1197
1188
|
// This reduces flicker when only a single line changes (e.g., spinner animation)
|
|
@@ -1232,15 +1223,16 @@ export class TUI extends Container {
|
|
|
1232
1223
|
}
|
|
1233
1224
|
|
|
1234
1225
|
// Track where cursor ended up after rendering (screen-relative).
|
|
1235
|
-
let finalCursorRow =
|
|
1226
|
+
let finalCursorRow =
|
|
1227
|
+
moveTargetScreenRow +
|
|
1228
|
+
(appendScroll ? 1 : 0) +
|
|
1229
|
+
Math.max(0, renderEnd - (appendScroll ? appendFrom : firstChanged));
|
|
1236
1230
|
|
|
1237
1231
|
// If we had more lines before, clear stale rows below new content without scrolling.
|
|
1238
1232
|
if (this.#previousLines.length > newLines.length) {
|
|
1239
1233
|
if (newLines.length === 0) {
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
}
|
|
1243
|
-
buffer += "\r\x1b[J";
|
|
1234
|
+
buffer += this.#moveToScreenPosition(0, 0);
|
|
1235
|
+
buffer += "\x1b[J";
|
|
1244
1236
|
finalCursorRow = 0;
|
|
1245
1237
|
} else {
|
|
1246
1238
|
const tailScreenRow = newLines.length - 1 - viewportTop;
|
|
@@ -1249,13 +1241,8 @@ export class TUI extends Container {
|
|
|
1249
1241
|
viewportRepaint();
|
|
1250
1242
|
return;
|
|
1251
1243
|
}
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
finalCursorRow = tailScreenRow;
|
|
1255
|
-
} else if (finalCursorRow > tailScreenRow) {
|
|
1256
|
-
buffer += `\x1b[${finalCursorRow - tailScreenRow}A`;
|
|
1257
|
-
finalCursorRow = tailScreenRow;
|
|
1258
|
-
}
|
|
1244
|
+
buffer += this.#moveToScreenPosition(tailScreenRow, 0);
|
|
1245
|
+
finalCursorRow = tailScreenRow;
|
|
1259
1246
|
if (tailScreenRow < height - 1) {
|
|
1260
1247
|
buffer += "\x1b[1B\r\x1b[J\x1b[1A";
|
|
1261
1248
|
}
|
|
@@ -1297,7 +1284,7 @@ export class TUI extends Container {
|
|
|
1297
1284
|
// Write entire buffer at once
|
|
1298
1285
|
this.terminal.write(buffer);
|
|
1299
1286
|
// cursorRow tracks end of content (for viewport calculation)
|
|
1300
|
-
// hardwareCursorRow tracks screen-relative cursor
|
|
1287
|
+
// hardwareCursorRow tracks last committed screen-relative cursor row
|
|
1301
1288
|
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
1302
1289
|
this.#hardwareCursorRow = cursorUpdate.row;
|
|
1303
1290
|
this.#lastCursorSequence = cursorUpdate.sequence;
|
|
@@ -1328,14 +1315,7 @@ export class TUI extends Container {
|
|
|
1328
1315
|
// Clamp cursor position to valid range
|
|
1329
1316
|
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
|
|
1330
1317
|
const targetCol = Math.max(0, cursorPos.col);
|
|
1331
|
-
let sequence =
|
|
1332
|
-
const rowDelta = targetRow - currentRow;
|
|
1333
|
-
if (rowDelta > 0) {
|
|
1334
|
-
sequence += `\x1b[${rowDelta}B`; // Move down
|
|
1335
|
-
} else if (rowDelta < 0) {
|
|
1336
|
-
sequence += `\x1b[${-rowDelta}A`; // Move up
|
|
1337
|
-
}
|
|
1338
|
-
sequence += `\x1b[${targetCol + 1}G`; // Move to absolute column (1-indexed)
|
|
1318
|
+
let sequence = this.#moveToScreenPosition(targetRow, targetCol);
|
|
1339
1319
|
sequence += this.#showHardwareCursor ? "\x1b[?25h" : "\x1b[?25l";
|
|
1340
1320
|
|
|
1341
1321
|
return { sequence, row: targetRow };
|