@oh-my-pi/pi-tui 13.5.7 → 13.6.0

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,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "13.5.7",
4
+ "version": "13.6.0",
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.7",
37
- "@oh-my-pi/pi-utils": "13.5.7",
36
+ "@oh-my-pi/pi-natives": "13.6.0",
37
+ "@oh-my-pi/pi-utils": "13.6.0",
38
38
  "marked": "^17.0"
39
39
  },
40
40
  "devDependencies": {
@@ -1,7 +1,7 @@
1
1
  import { matchesKey } from "../keys";
2
2
  import type { SymbolTheme } from "../symbols";
3
3
  import type { Component } from "../tui";
4
- import { Ellipsis, padding, truncateToWidth, visibleWidth } from "../utils";
4
+ import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
5
5
 
6
6
  export interface SelectItem {
7
7
  value: string;
@@ -20,6 +20,13 @@ export interface SelectListTheme {
20
20
  symbols: SymbolTheme;
21
21
  }
22
22
 
23
+ function sanitizeSingleLine(text: string): string {
24
+ return replaceTabs(text)
25
+ .replace(/[\r\n]+/g, " ")
26
+ .replace(/\s+/g, " ")
27
+ .trim();
28
+ }
29
+
23
30
  export class SelectList implements Component {
24
31
  #filteredItems: ReadonlyArray<SelectItem>;
25
32
  #selectedIndex: number = 0;
@@ -72,15 +79,17 @@ export class SelectList implements Component {
72
79
  if (!item) continue;
73
80
 
74
81
  const isSelected = i === this.#selectedIndex;
82
+ const labelText = sanitizeSingleLine(item.label || item.value);
83
+ const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
75
84
 
76
85
  let line = "";
77
86
  if (isSelected) {
78
87
  // Use arrow indicator for selection - entire line uses selectedText color
79
88
  const prefix = `${this.theme.symbols.cursor} `;
80
89
  const prefixWidth = visibleWidth(prefix);
81
- const displayValue = item.label || item.value;
90
+ const displayValue = labelText;
82
91
 
83
- if (item.description && width > 40) {
92
+ if (descriptionText && width > 40) {
84
93
  // Calculate how much space we have for value + description
85
94
  const maxValueWidth = Math.min(30, width - prefixWidth - 4);
86
95
  const truncatedValue = truncateToWidth(displayValue, maxValueWidth, Ellipsis.Omit);
@@ -91,7 +100,7 @@ export class SelectList implements Component {
91
100
  const remainingWidth = width - descriptionStart - 2; // -2 for safety
92
101
 
93
102
  if (remainingWidth > 10) {
94
- const truncatedDesc = truncateToWidth(item.description, remainingWidth, Ellipsis.Omit);
103
+ const truncatedDesc = truncateToWidth(descriptionText, remainingWidth, Ellipsis.Omit);
95
104
  // Apply selectedText to entire line content
96
105
  line = this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
97
106
  } else {
@@ -105,10 +114,10 @@ export class SelectList implements Component {
105
114
  line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, Ellipsis.Omit)}`);
106
115
  }
107
116
  } else {
108
- const displayValue = item.label || item.value;
117
+ const displayValue = labelText;
109
118
  const prefix = padding(visibleWidth(this.theme.symbols.cursor) + 1);
110
119
 
111
- if (item.description && width > 40) {
120
+ if (descriptionText && width > 40) {
112
121
  // Calculate how much space we have for value + description
113
122
  const maxValueWidth = Math.min(30, width - prefix.length - 4);
114
123
  const truncatedValue = truncateToWidth(displayValue, maxValueWidth, Ellipsis.Omit);
@@ -119,7 +128,7 @@ export class SelectList implements Component {
119
128
  const remainingWidth = width - descriptionStart - 2; // -2 for safety
120
129
 
121
130
  if (remainingWidth > 10) {
122
- const truncatedDesc = truncateToWidth(item.description, remainingWidth, Ellipsis.Omit);
131
+ const truncatedDesc = truncateToWidth(descriptionText, remainingWidth, Ellipsis.Omit);
123
132
  const descText = this.theme.description(spacing + truncatedDesc);
124
133
  line = prefix + truncatedValue + descText;
125
134
  } else {
package/src/tui.ts CHANGED
@@ -204,7 +204,6 @@ export class TUI extends Container {
204
204
  terminal: Terminal;
205
205
  #previousLines: string[] = [];
206
206
  #previousWidth = 0;
207
- #previousHeight = 0;
208
207
  #focusedComponent: Component | null = null;
209
208
  #inputListeners = new Set<InputListener>();
210
209
 
@@ -212,7 +211,7 @@ export class TUI extends Container {
212
211
  onDebug?: () => void;
213
212
  #renderRequested = false;
214
213
  #cursorRow = 0; // Logical cursor row (end of rendered content)
215
- #hardwareCursorRow = 0; // Screen-relative terminal cursor row (0..rows-1)
214
+ #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
216
215
  #viewportTopRow = 0; // Content row currently mapped to screen row 0
217
216
  #inputBuffer = ""; // Buffer for parsing terminal responses
218
217
  #cellSizeQueryPending = false;
@@ -226,7 +225,6 @@ export class TUI extends Container {
226
225
  #maxLinesRendered = 0; // High-water line count used for clear-on-shrink policy
227
226
  #fullRedrawCount = 0;
228
227
  #stopped = false;
229
- #lastCursorSequence = ""; // Last cursor escape sequence emitted (for no-op dedup)
230
228
 
231
229
  // Overlay stack for modal components rendered on top of base content
232
230
  overlayStack: {
@@ -541,13 +539,9 @@ export class TUI extends Container {
541
539
  stop(): void {
542
540
  this.#clearSixelProbeState();
543
541
  this.#stopped = true;
544
- // Move cursor below the visible working area to prevent overwriting/artifacts on exit
542
+ // Move cursor to the end of the content to prevent overwriting/artifacts on exit
545
543
  if (this.#previousLines.length > 0) {
546
- const visibleLineCount = Math.max(
547
- 0,
548
- Math.min(this.terminal.rows, this.#previousLines.length - this.#viewportTopRow),
549
- );
550
- const targetRow = Math.min(visibleLineCount, Math.max(0, this.terminal.rows - 1));
544
+ const targetRow = this.#previousLines.length; // Line after the last content
551
545
  const lineDiff = targetRow - this.#hardwareCursorRow;
552
546
  if (lineDiff > 0) {
553
547
  this.terminal.write(`\x1b[${lineDiff}B`);
@@ -565,12 +559,10 @@ export class TUI extends Container {
565
559
  if (force) {
566
560
  this.#previousLines = [];
567
561
  this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
568
- this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
569
562
  this.#cursorRow = 0;
570
563
  this.#hardwareCursorRow = 0;
571
564
  this.#viewportTopRow = 0;
572
565
  this.#maxLinesRendered = 0;
573
- this.#lastCursorSequence = "";
574
566
  }
575
567
  if (this.#renderRequested) return;
576
568
  this.#renderRequested = true;
@@ -988,17 +980,18 @@ export class TUI extends Container {
988
980
  return null;
989
981
  }
990
982
 
991
- #moveToScreenPosition(row: number, col = 0): string {
992
- const safeRow = Math.max(0, row);
993
- const safeCol = Math.max(0, col);
994
- return `\x1b[${safeRow + 1};${safeCol + 1}H`;
995
- }
996
-
997
983
  #doRender(): void {
998
984
  if (this.#stopped) return;
999
985
  const width = this.terminal.columns;
1000
986
  const height = this.terminal.rows;
1001
- const hardwareCursorRow = this.#hardwareCursorRow;
987
+ let viewportTop = Math.max(0, this.#maxLinesRendered - height);
988
+ let prevViewportTop = this.#viewportTopRow;
989
+ let hardwareCursorRow = this.#hardwareCursorRow;
990
+ const computeLineDiff = (targetRow: number): number => {
991
+ const currentScreenRow = hardwareCursorRow - prevViewportTop;
992
+ const targetScreenRow = targetRow - viewportTop;
993
+ return targetScreenRow - currentScreenRow;
994
+ };
1002
995
 
1003
996
  // Render all components to get new lines
1004
997
  let newLines = this.render(width);
@@ -1013,103 +1006,32 @@ export class TUI extends Container {
1013
1006
 
1014
1007
  newLines = this.#applyLineResets(newLines);
1015
1008
 
1016
- const previousViewportTop = this.#viewportTopRow;
1017
- const previousViewportBottom = previousViewportTop + height - 1;
1018
- const viewportTop = Math.max(0, newLines.length - height);
1019
-
1020
1009
  // Width changed - need full re-render (line wrapping changes)
1021
1010
  const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
1022
- const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
1023
1011
 
1024
- // === Hard reset: clear scrollback + viewport, write ALL content lines from 0. ===
1025
- // Used only for first render and width changes (scrollback is stale at old width).
1026
- // After clearing, writing all lines naturally populates scrollback so the user
1027
- // can scroll through history.
1028
- const hardReset = (clear: boolean): void => {
1012
+ // Helper to clear scrollback and viewport and render all new lines
1013
+ const fullRender = (clear: boolean): void => {
1029
1014
  this.#fullRedrawCount += 1;
1030
- const overflow = Math.max(0, newLines.length - height);
1031
1015
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1032
- if (clear) {
1033
- // Clear scrollback + home + clear viewport.
1034
- // \x1b[H always homes — does not depend on hardwareCursorRow being correct.
1035
- buffer += "\x1b[3J\x1b[H\x1b[J";
1036
- }
1016
+ if (clear) buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
1037
1017
  for (let i = 0; i < newLines.length; i++) {
1038
1018
  if (i > 0) buffer += "\r\n";
1039
1019
  buffer += newLines[i];
1040
1020
  }
1041
- // After writing N lines, cursor is at screen row min(N-1, height-1).
1042
- // Lines above the viewport scrolled into scrollback naturally.
1043
- const screenCursorRow = Math.max(0, Math.min(newLines.length - 1, height - 1));
1044
- const visibleLines = Math.min(newLines.length, height);
1045
- const renderCursorPos = cursorPos ? { row: Math.max(0, cursorPos.row - overflow), col: cursorPos.col } : null;
1046
- const cursorUpdate = this.#buildHardwareCursorSequence(renderCursorPos, visibleLines, screenCursorRow);
1047
- buffer += cursorUpdate.sequence;
1048
1021
  buffer += "\x1b[?2026l"; // End synchronized output
1049
1022
  this.terminal.write(buffer);
1050
1023
  this.#cursorRow = Math.max(0, newLines.length - 1);
1051
- this.#hardwareCursorRow = cursorUpdate.row;
1052
- this.#lastCursorSequence = cursorUpdate.sequence;
1053
- this.#viewportTopRow = overflow;
1054
- // Reset high-water on clearing, otherwise track growth
1024
+ this.#hardwareCursorRow = this.#cursorRow;
1025
+ // Reset max lines when clearing, otherwise track growth
1055
1026
  if (clear) {
1056
1027
  this.#maxLinesRendered = newLines.length;
1057
1028
  } else {
1058
1029
  this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length);
1059
1030
  }
1031
+ this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
1032
+ this.#positionHardwareCursor(cursorPos, newLines.length);
1060
1033
  this.#previousLines = newLines;
1061
1034
  this.#previousWidth = width;
1062
- this.#previousHeight = height;
1063
- };
1064
-
1065
- // === Viewport repaint: navigate to top of owned area, clear downward, ===
1066
- // === write only the visible viewport lines. ===
1067
- // Used for height changes, content shrink, and all diff fallback paths.
1068
- // Key properties:
1069
- // - Uses absolute screen addressing for stable cursor anchoring
1070
- // - Never uses \x1b[3J — preserves scrollback history
1071
- // - Writes only viewport-visible lines — no intermediate states
1072
- const viewportRepaint = (): void => {
1073
- this.#fullRedrawCount += 1;
1074
- const overflow = Math.max(0, newLines.length - height);
1075
- const viewportLines = newLines.length > height ? newLines.slice(overflow, overflow + height) : newLines;
1076
-
1077
- let buffer = "\x1b[?2026h"; // Begin synchronized output
1078
-
1079
- const resizeAutoScroll = heightChanged ? Math.max(0, this.#previousHeight - height) : 0;
1080
- const scrollNeeded = overflow - previousViewportTop - resizeAutoScroll;
1081
- if (scrollNeeded > 0) {
1082
- buffer += this.#moveToScreenPosition(height - 1, 0);
1083
- buffer += "\r\n".repeat(Math.min(scrollNeeded, height));
1084
- }
1085
- // Move cursor to top-left of the viewport using absolute addressing.
1086
- buffer += this.#moveToScreenPosition(0, 0);
1087
-
1088
- // Clear from here downward — erases old content and any stale rows below.
1089
- // Does NOT touch scrollback above us.
1090
- buffer += "\x1b[0J";
1091
-
1092
- // Write only the viewport lines
1093
- for (let i = 0; i < viewportLines.length; i++) {
1094
- if (i > 0) buffer += "\r\n";
1095
- buffer += viewportLines[i];
1096
- }
1097
-
1098
- // Cursor is now at the last written viewport line
1099
- const screenCursorRow = Math.max(0, viewportLines.length - 1);
1100
- const renderCursorPos = cursorPos ? { row: Math.max(0, cursorPos.row - overflow), col: cursorPos.col } : null;
1101
- const cursorUpdate = this.#buildHardwareCursorSequence(renderCursorPos, viewportLines.length, screenCursorRow);
1102
- buffer += cursorUpdate.sequence;
1103
- buffer += "\x1b[?2026l"; // End synchronized output
1104
- this.terminal.write(buffer);
1105
- this.#cursorRow = Math.max(0, newLines.length - 1);
1106
- this.#hardwareCursorRow = cursorUpdate.row;
1107
- this.#lastCursorSequence = cursorUpdate.sequence;
1108
- this.#viewportTopRow = overflow;
1109
- this.#maxLinesRendered = newLines.length;
1110
- this.#previousLines = newLines;
1111
- this.#previousWidth = width;
1112
- this.#previousHeight = height;
1113
1035
  };
1114
1036
 
1115
1037
  const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
@@ -1123,44 +1045,27 @@ export class TUI extends Container {
1123
1045
  // First render - just output everything without clearing (assumes clean screen)
1124
1046
  if (this.#previousLines.length === 0 && !widthChanged) {
1125
1047
  logRedraw("first render");
1126
- hardReset(false);
1048
+ fullRender(false);
1127
1049
  return;
1128
1050
  }
1129
1051
 
1130
1052
  // Width changed - full re-render (line wrapping changes)
1131
1053
  if (widthChanged) {
1132
1054
  logRedraw(`width changed (${this.#previousWidth} -> ${width})`);
1133
- hardReset(true);
1134
- return;
1135
- }
1136
-
1137
- // Height changed - repaint viewport (scrollback content is still valid)
1138
- if (heightChanged) {
1139
- logRedraw(`height changed (${this.#previousHeight} -> ${height})`);
1140
- viewportRepaint();
1055
+ fullRender(true);
1141
1056
  return;
1142
1057
  }
1143
1058
 
1144
- // Content shrunk below the working area and no overlays - re-render to clear empty rows.
1145
- // When an overlay is active, avoid clearing to reduce flicker and avoid resetting scrollback.
1059
+ // Content shrunk below the working area and no overlays - re-render to clear empty rows
1060
+ // (overlays need the padding, so only do this when no overlays are active)
1146
1061
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1147
1062
  if (this.#clearOnShrink && newLines.length < this.#maxLinesRendered && this.overlayStack.length === 0) {
1148
1063
  logRedraw(`clearOnShrink (maxLinesRendered=${this.#maxLinesRendered})`);
1149
- viewportRepaint();
1064
+ fullRender(true);
1150
1065
  return;
1151
1066
  }
1152
1067
 
1153
- // When content shrinks while previous content overflowed the viewport, force a
1154
- // viewport-scoped full redraw to re-anchor the visible tail and avoid drift.
1155
- if (newLines.length < this.#previousLines.length && this.#previousLines.length > height) {
1156
- logRedraw(`overflow shrink (${this.#previousLines.length} -> ${newLines.length}, height=${height})`);
1157
- viewportRepaint();
1158
- return;
1159
- }
1160
-
1161
- // Viewport-top shifts are only safe to patch incrementally for pure append-scroll.
1162
- // Mixed updates can remap screen rows and leave stale content behind.
1163
- // We detect and fall back to viewportRepaint() below after computing the diff span.
1068
+ // Find first and last changed lines
1164
1069
  let firstChanged = -1;
1165
1070
  let lastChanged = -1;
1166
1071
  const maxLines = Math.max(newLines.length, this.#previousLines.length);
@@ -1175,155 +1080,102 @@ export class TUI extends Container {
1175
1080
  lastChanged = i;
1176
1081
  }
1177
1082
  }
1178
- const previousLineCount = this.#previousLines.length;
1179
- const appendedLines = newLines.length > previousLineCount;
1083
+ const appendedLines = newLines.length > this.#previousLines.length;
1180
1084
  if (appendedLines) {
1181
1085
  if (firstChanged === -1) {
1182
- firstChanged = previousLineCount;
1086
+ firstChanged = this.#previousLines.length;
1183
1087
  }
1184
1088
  lastChanged = newLines.length - 1;
1185
1089
  }
1186
-
1187
- const pureAppendStart = appendedLines && firstChanged === previousLineCount && firstChanged > 0;
1188
- let canAppendWithOffscreenChanges = false;
1189
- if (appendedLines && firstChanged >= 0 && firstChanged < previousViewportTop && previousViewportTop > 0) {
1190
- canAppendWithOffscreenChanges = true;
1191
- for (let i = previousViewportTop; i < previousLineCount; i++) {
1192
- if (this.#previousLines[i] !== newLines[i]) {
1193
- canAppendWithOffscreenChanges = false;
1194
- break;
1195
- }
1196
- }
1197
- }
1198
- const appendCandidate = pureAppendStart || canAppendWithOffscreenChanges;
1199
- const appendFrom = appendCandidate && !pureAppendStart ? previousLineCount : firstChanged;
1090
+ const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
1200
1091
 
1201
1092
  // No changes - but still need to update hardware cursor position if it moved
1202
1093
  if (firstChanged === -1) {
1203
- this.#positionHardwareCursor(cursorPos, viewportTop, height);
1204
- this.#viewportTopRow = viewportTop;
1205
- return;
1206
- }
1207
- const renderEnd = Math.min(lastChanged, newLines.length - 1);
1208
- const viewportShifted = viewportTop !== previousViewportTop;
1209
- const appendScroll = appendCandidate && renderEnd >= appendFrom && renderEnd > previousViewportBottom;
1210
- if (viewportShifted && !appendScroll) {
1211
- logRedraw(
1212
- `viewport shift fallback (prevTop=${previousViewportTop}, top=${viewportTop}, first=${firstChanged}, end=${renderEnd}, appendScroll=${appendScroll}, appendFrom=${appendFrom})`,
1213
- );
1214
- viewportRepaint();
1094
+ this.#positionHardwareCursor(cursorPos, newLines.length);
1095
+ this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
1215
1096
  return;
1216
1097
  }
1217
1098
 
1218
1099
  // All changes are in deleted lines (nothing to render, just clear)
1219
1100
  if (firstChanged >= newLines.length) {
1220
- const extraLines = this.#previousLines.length - newLines.length;
1221
- if (extraLines > height) {
1222
- logRedraw(`deletedLines > height (${extraLines} > ${height})`);
1223
- viewportRepaint();
1224
- return;
1225
- }
1226
- const targetRow = Math.max(0, newLines.length - 1);
1227
- const targetScreenRow = targetRow - previousViewportTop;
1228
- if (targetScreenRow < 0 || targetScreenRow >= height) {
1229
- logRedraw(`deleted-line target offscreen (${targetScreenRow})`);
1230
- viewportRepaint();
1231
- return;
1232
- }
1233
- let buffer = "\x1b[?2026h";
1234
- buffer += this.#moveToScreenPosition(targetScreenRow, 0);
1235
- // Erase stale rows below the new tail without scrolling.
1236
- if (newLines.length > 0) {
1237
- if (targetScreenRow < height - 1) {
1238
- buffer += "\x1b[1B\r\x1b[J\x1b[1A";
1101
+ if (this.#previousLines.length > newLines.length) {
1102
+ let buffer = "\x1b[?2026h";
1103
+ // Move to end of new content (clamp to 0 for empty content)
1104
+ const targetRow = Math.max(0, newLines.length - 1);
1105
+ const lineDiff = computeLineDiff(targetRow);
1106
+ if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
1107
+ else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
1108
+ buffer += "\r";
1109
+ // Clear extra lines without scrolling
1110
+ const extraLines = this.#previousLines.length - newLines.length;
1111
+ if (extraLines > height) {
1112
+ logRedraw(`extraLines > height (${extraLines} > ${height})`);
1113
+ fullRender(true);
1114
+ return;
1239
1115
  }
1240
- } else {
1241
- buffer += "\x1b[J";
1116
+ if (extraLines > 0) {
1117
+ buffer += "\x1b[1B";
1118
+ }
1119
+ for (let i = 0; i < extraLines; i++) {
1120
+ buffer += "\r\x1b[2K";
1121
+ if (i < extraLines - 1) buffer += "\x1b[1B";
1122
+ }
1123
+ if (extraLines > 0) {
1124
+ buffer += `\x1b[${extraLines}A`;
1125
+ }
1126
+ buffer += "\x1b[?2026l";
1127
+ this.terminal.write(buffer);
1128
+ this.#cursorRow = targetRow;
1129
+ this.#hardwareCursorRow = targetRow;
1242
1130
  }
1243
- const cursorPosScreen = cursorPos
1244
- ? { row: Math.max(0, cursorPos.row - viewportTop), col: cursorPos.col }
1245
- : null;
1246
- const cursorUpdate = this.#buildHardwareCursorSequence(cursorPosScreen, height, targetScreenRow);
1247
- buffer += cursorUpdate.sequence;
1248
- buffer += "\x1b[?2026l";
1249
- this.terminal.write(buffer);
1250
- this.#hardwareCursorRow = cursorUpdate.row;
1251
- this.#lastCursorSequence = cursorUpdate.sequence;
1252
- this.#cursorRow = targetRow;
1253
- this.#viewportTopRow = viewportTop;
1254
- this.#maxLinesRendered = newLines.length;
1131
+ this.#positionHardwareCursor(cursorPos, newLines.length);
1255
1132
  this.#previousLines = newLines;
1256
1133
  this.#previousWidth = width;
1257
- this.#previousHeight = height;
1134
+ this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
1258
1135
  return;
1259
1136
  }
1260
1137
 
1261
1138
  // Check if firstChanged is above what was previously visible
1262
1139
  // Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
1263
- const previousContentViewportTop = previousViewportTop;
1264
- if (!appendScroll && firstChanged < previousContentViewportTop) {
1265
- // First change is above previous viewport - force a viewport-anchored full re-render.
1140
+ const previousContentViewportTop = Math.max(0, this.#previousLines.length - height);
1141
+ if (firstChanged < previousContentViewportTop) {
1142
+ // First change is above previous viewport - need full re-render
1266
1143
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`);
1267
- viewportRepaint();
1144
+ fullRender(true);
1268
1145
  return;
1269
1146
  }
1270
1147
 
1271
1148
  // Render from first changed line to end
1272
1149
  // Build buffer with all updates wrapped in synchronized output
1273
1150
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1274
- const moveTargetRow = appendScroll ? appendFrom - 1 : firstChanged;
1275
- const moveTargetScreenRow = moveTargetRow - previousViewportTop;
1276
- if (appendScroll) {
1277
- let appendBuffer = "\x1b[?2026h";
1278
- appendBuffer += this.#moveToScreenPosition(moveTargetScreenRow, 0);
1279
- for (let i = appendFrom; i <= renderEnd; i++) {
1280
- appendBuffer += "\r\n\x1b[2K";
1281
- const line = newLines[i];
1282
- const isImage = TERMINAL.isImageLine(line);
1283
- if (!isImage && visibleWidth(line) > width) {
1284
- logRedraw(`append overflow width fallback at line ${i}`);
1285
- viewportRepaint();
1286
- return;
1287
- }
1288
- appendBuffer += line;
1151
+ const prevViewportBottom = prevViewportTop + height - 1;
1152
+ const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
1153
+ if (moveTargetRow > prevViewportBottom) {
1154
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
1155
+ const moveToBottom = height - 1 - currentScreenRow;
1156
+ if (moveToBottom > 0) {
1157
+ buffer += `\x1b[${moveToBottom}B`;
1289
1158
  }
1290
- const appendEndScreenRow = Math.min(height - 1, moveTargetScreenRow + (renderEnd - appendFrom + 1));
1291
- const cursorPosScreen = cursorPos
1292
- ? { row: Math.max(0, cursorPos.row - viewportTop), col: cursorPos.col }
1293
- : null;
1294
- const appendCursorUpdate = this.#buildHardwareCursorSequence(cursorPosScreen, height, appendEndScreenRow);
1295
- appendBuffer += appendCursorUpdate.sequence;
1296
- appendBuffer += "\x1b[?2026l";
1297
- this.terminal.write(appendBuffer);
1298
- this.#cursorRow = Math.max(0, newLines.length - 1);
1299
- this.#hardwareCursorRow = appendCursorUpdate.row;
1300
- this.#lastCursorSequence = appendCursorUpdate.sequence;
1301
- this.#viewportTopRow = viewportTop;
1302
- this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length);
1303
- this.#previousLines = newLines;
1304
- this.#previousWidth = width;
1305
- this.#previousHeight = height;
1306
- return;
1159
+ const scroll = moveTargetRow - prevViewportBottom;
1160
+ buffer += "\r\n".repeat(scroll);
1161
+ prevViewportTop += scroll;
1162
+ viewportTop += scroll;
1163
+ hardwareCursorRow = moveTargetRow;
1307
1164
  }
1308
- if (
1309
- moveTargetScreenRow < 0 ||
1310
- moveTargetScreenRow >= height ||
1311
- (!appendScroll && renderEnd > previousViewportBottom)
1312
- ) {
1313
- logRedraw(
1314
- `offscreen diff fallback (move=${moveTargetScreenRow}, renderEnd=${renderEnd}, viewportBottom=${previousViewportBottom})`,
1315
- );
1316
- viewportRepaint();
1317
- return;
1165
+
1166
+ // Move cursor to first changed line (use hardwareCursorRow for actual position)
1167
+ const lineDiff = computeLineDiff(moveTargetRow);
1168
+ if (lineDiff > 0) {
1169
+ buffer += `\x1b[${lineDiff}B`; // Move down
1170
+ } else if (lineDiff < 0) {
1171
+ buffer += `\x1b[${-lineDiff}A`; // Move up
1318
1172
  }
1319
1173
 
1320
- // Move cursor to first changed line (screen-relative) using absolute coordinates.
1321
- const lineDiff = moveTargetScreenRow - hardwareCursorRow;
1322
- buffer += this.#moveToScreenPosition(moveTargetScreenRow, 0);
1323
- if (appendScroll) buffer += "\r\n";
1174
+ buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
1324
1175
 
1325
1176
  // Only render changed lines (firstChanged to lastChanged), not all lines to end
1326
1177
  // This reduces flicker when only a single line changes (e.g., spinner animation)
1178
+ const renderEnd = Math.min(lastChanged, newLines.length - 1);
1327
1179
  for (let i = firstChanged; i <= renderEnd; i++) {
1328
1180
  if (i > firstChanged) buffer += "\r\n";
1329
1181
  buffer += "\x1b[2K"; // Clear current line
@@ -1360,37 +1212,27 @@ export class TUI extends Container {
1360
1212
  buffer += line;
1361
1213
  }
1362
1214
 
1363
- // Track where cursor ended up after rendering (screen-relative).
1364
- let finalCursorRow =
1365
- moveTargetScreenRow +
1366
- (appendScroll ? 1 : 0) +
1367
- Math.max(0, renderEnd - (appendScroll ? appendFrom : firstChanged));
1215
+ // Track where cursor ended up after rendering
1216
+ let finalCursorRow = renderEnd;
1368
1217
 
1369
- // If we had more lines before, clear stale rows below new content without scrolling.
1218
+ // If we had more lines before, clear them and move cursor back
1370
1219
  if (this.#previousLines.length > newLines.length) {
1371
- if (newLines.length === 0) {
1372
- buffer += this.#moveToScreenPosition(0, 0);
1373
- buffer += "\x1b[J";
1374
- finalCursorRow = 0;
1375
- } else {
1376
- const tailScreenRow = newLines.length - 1 - viewportTop;
1377
- if (tailScreenRow < 0 || tailScreenRow >= height) {
1378
- logRedraw(`tail row offscreen during stale cleanup (${tailScreenRow})`);
1379
- viewportRepaint();
1380
- return;
1381
- }
1382
- buffer += this.#moveToScreenPosition(tailScreenRow, 0);
1383
- finalCursorRow = tailScreenRow;
1384
- if (tailScreenRow < height - 1) {
1385
- buffer += "\x1b[1B\r\x1b[J\x1b[1A";
1386
- }
1220
+ // Move to end of new content first if we stopped before it
1221
+ if (renderEnd < newLines.length - 1) {
1222
+ const moveDown = newLines.length - 1 - renderEnd;
1223
+ buffer += `\x1b[${moveDown}B`;
1224
+ finalCursorRow = newLines.length - 1;
1387
1225
  }
1226
+ const extraLines = this.#previousLines.length - newLines.length;
1227
+ for (let i = newLines.length; i < this.#previousLines.length; i++) {
1228
+ buffer += "\r\n\x1b[2K";
1229
+ }
1230
+ // Move cursor back to end of new content
1231
+ buffer += `\x1b[${extraLines}A`;
1388
1232
  }
1389
1233
 
1390
- const cursorPosScreen = cursorPos ? { row: Math.max(0, cursorPos.row - viewportTop), col: cursorPos.col } : null;
1391
- const cursorUpdate = this.#buildHardwareCursorSequence(cursorPosScreen, height, finalCursorRow);
1392
- buffer += cursorUpdate.sequence;
1393
1234
  buffer += "\x1b[?2026l"; // End synchronized output
1235
+
1394
1236
  if (process.env.PI_TUI_DEBUG === "1") {
1395
1237
  const debugDir = "/tmp/tui";
1396
1238
  fs.mkdirSync(debugDir, { recursive: true });
@@ -1419,63 +1261,54 @@ export class TUI extends Container {
1419
1261
  ].join("\n");
1420
1262
  fs.writeFileSync(debugPath, debugData);
1421
1263
  }
1264
+
1422
1265
  // Write entire buffer at once
1423
1266
  this.terminal.write(buffer);
1267
+
1268
+ // Track cursor position for next render
1424
1269
  // cursorRow tracks end of content (for viewport calculation)
1425
- // hardwareCursorRow tracks last committed screen-relative cursor row
1270
+ // hardwareCursorRow tracks actual terminal cursor position (for movement)
1426
1271
  this.#cursorRow = Math.max(0, newLines.length - 1);
1427
- this.#hardwareCursorRow = cursorUpdate.row;
1428
- this.#lastCursorSequence = cursorUpdate.sequence;
1429
- this.#viewportTopRow = viewportTop;
1430
- // Track terminal high-water mark for clear-on-shrink behavior.
1431
- if (this.#previousLines.length > newLines.length) {
1432
- this.#maxLinesRendered = newLines.length;
1433
- } else {
1434
- this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length);
1435
- }
1272
+ this.#hardwareCursorRow = finalCursorRow;
1273
+ // Track terminal's working area (grows but doesn't shrink unless cleared)
1274
+ this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length);
1275
+ this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
1276
+
1277
+ // Position hardware cursor for IME
1278
+ this.#positionHardwareCursor(cursorPos, newLines.length);
1279
+
1436
1280
  this.#previousLines = newLines;
1437
1281
  this.#previousWidth = width;
1438
- this.#previousHeight = height;
1439
1282
  }
1440
1283
 
1441
1284
  /**
1442
- * Build cursor movement and visibility escape sequence and return resulting row.
1443
- * Used by differential and direct cursor updates to keep movement logic consistent.
1285
+ * Position the hardware cursor for IME candidate window.
1286
+ * @param cursorPos The cursor position extracted from rendered output, or null
1287
+ * @param totalLines Total number of rendered lines
1444
1288
  */
1445
- #buildHardwareCursorSequence(
1446
- cursorPos: { row: number; col: number } | null,
1447
- totalLines: number,
1448
- currentRow: number,
1449
- ): { sequence: string; row: number } {
1289
+ #positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {
1450
1290
  if (!cursorPos || totalLines <= 0) {
1451
- return { sequence: "\x1b[?25l", row: currentRow };
1291
+ this.terminal.hideCursor();
1292
+ return;
1452
1293
  }
1294
+
1453
1295
  // Clamp cursor position to valid range
1454
1296
  const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
1455
1297
  const targetCol = Math.max(0, cursorPos.col);
1456
- let sequence = this.#moveToScreenPosition(targetRow, targetCol);
1457
- sequence += this.#showHardwareCursor ? "\x1b[?25h" : "\x1b[?25l";
1458
1298
 
1459
- return { sequence, row: targetRow };
1460
- }
1461
-
1462
- /**
1463
- * Position the hardware cursor for IME candidate window.
1464
- * @param cursorPos The cursor position extracted from rendered output, or null
1465
- * @param viewportTop Content row currently mapped to screen row 0
1466
- * @param height Visible terminal height
1467
- */
1468
- #positionHardwareCursor(cursorPos: { row: number; col: number } | null, viewportTop: number, height: number): void {
1469
- const screenCursorPos = cursorPos ? { row: Math.max(0, cursorPos.row - viewportTop), col: cursorPos.col } : null;
1470
- const update = this.#buildHardwareCursorSequence(screenCursorPos, height, this.#hardwareCursorRow);
1471
- // Skip write if cursor position and visibility haven't changed.
1472
- // This avoids emitting escape sequences on idle ticks (e.g., spinner frames
1473
- // that don't change content), which can interfere with user scrolling.
1474
- if (update.row === this.#hardwareCursorRow && update.sequence === this.#lastCursorSequence) {
1475
- return;
1299
+ // Move cursor from current position to target
1300
+ const rowDelta = targetRow - this.#hardwareCursorRow;
1301
+ let buffer = "";
1302
+ if (rowDelta > 0) {
1303
+ buffer += `\x1b[${rowDelta}B`; // Move down
1304
+ } else if (rowDelta < 0) {
1305
+ buffer += `\x1b[${-rowDelta}A`; // Move up
1476
1306
  }
1477
- this.#lastCursorSequence = update.sequence;
1478
- this.terminal.write(`\x1b[?2026h${update.sequence}\x1b[?2026l`);
1479
- this.#hardwareCursorRow = update.row;
1307
+ // Move to absolute column (1-indexed)
1308
+ buffer += `\x1b[${targetCol + 1}G`;
1309
+ buffer += this.#showHardwareCursor ? "\x1b[?25h" : "\x1b[?25l";
1310
+
1311
+ this.terminal.write(`\x1b[?2026h${buffer}\x1b[?2026l`);
1312
+ this.#hardwareCursorRow = targetRow;
1480
1313
  }
1481
1314
  }