@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.
Files changed (3) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +3 -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.2",
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.2",
37
- "@oh-my-pi/pi-utils": "13.5.2",
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
- // - Never uses \x1b[H (home) avoids scroll-to-top flash
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 from current position to screen row 0 (top of our owned area)
942
- if (hardwareCursorRow > 0) {
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 appendedLines = newLines.length > this.#previousLines.length;
1040
+ const previousLineCount = this.#previousLines.length;
1041
+ const appendedLines = newLines.length > previousLineCount;
1038
1042
  if (appendedLines) {
1039
1043
  if (firstChanged === -1) {
1040
- firstChanged = this.#previousLines.length;
1044
+ firstChanged = previousLineCount;
1041
1045
  }
1042
1046
  lastChanged = newLines.length - 1;
1043
1047
  }
1044
- const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
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 simpleAppendScroll = appendStart && renderEnd > previousViewportBottom;
1055
- if (viewportShifted && !simpleAppendScroll) {
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}, appendStart=${appendStart})`,
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
- const lineDiff = targetScreenRow - hardwareCursorRow;
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 = appendStart ? firstChanged - 1 : firstChanged;
1136
+ const moveTargetRow = appendScroll ? appendFrom - 1 : firstChanged;
1128
1137
  const moveTargetScreenRow = moveTargetRow - previousViewportTop;
1129
- if (appendStart && renderEnd > previousViewportBottom) {
1138
+ if (appendScroll) {
1130
1139
  let appendBuffer = "\x1b[?2026h";
1131
- const appendLineDiff = moveTargetScreenRow - hardwareCursorRow;
1132
- if (!Number.isFinite(appendLineDiff) || Math.abs(appendLineDiff) > height * 2) {
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 - firstChanged + 1));
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
- (!appendStart && renderEnd > previousViewportBottom)
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
- if (!Number.isFinite(lineDiff) || Math.abs(lineDiff) > height * 2) {
1184
- logRedraw(`large diff delta (${lineDiff})`);
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 = moveTargetScreenRow + (appendStart ? 1 : 0) + Math.max(0, renderEnd - firstChanged);
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
- if (finalCursorRow > 0) {
1241
- buffer += `\x1b[${finalCursorRow}A`;
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
- if (finalCursorRow < tailScreenRow) {
1253
- buffer += `\x1b[${tailScreenRow - finalCursorRow}B`;
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 position used for relative movement
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 };