@oh-my-pi/pi-tui 15.3.2 → 15.4.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/CHANGELOG.md CHANGED
@@ -2,11 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.4.0] - 2026-05-26
6
+
7
+ ### Fixed
8
+
9
+ - Fixed terminal scrollback gaining duplicate copies of the welcome screen (and any other header content) when the bottom tool cell mutated across the previous viewport boundary. Once a row scrolls into terminal history it cannot be retracted, so a subsequent shrink that would re-expose that row in the repainted viewport now clears stale scrollback and replays the transcript, then suppresses one immediate suffix-scroll frame so live status/editor chrome is not deposited twice. Multiplexer panes ignore `\x1b[3J`, so the recovery is gated on `!isMultiplexerSession()`.
10
+ - Fixed the IME / hardware cursor sticking to the bottom of the terminal after a resize that grew the viewport taller than the rendered transcript. `#emitViewportRepaint` always writes one row per screen line (padding empty rows past the content), so the post-write hardware cursor sits at screen row `height - 1`. The bookkeeping previously clamped the tracked cursor row to `lines.length - 1`, making `#cursorControlSequence`'s relative `rowDelta` underestimate the upward move by `(height - lines.length)` rows and pinning the cursor at the viewport bottom even though the focused component's `CURSOR_MARKER` was on a content row.
11
+
5
12
  ## [15.3.2] - 2026-05-25
6
13
 
7
14
  ### Fixed
8
15
 
9
16
  - Fixed `matchesKey(data, "ctrl+m")` (and the other named-key collisions: `ctrl+h`/`ctrl+i`/`ctrl+j`/`ctrl+[`) returning true for the bare `\r`/`\x08`/`\t`/`\n`/`\x1b` byte terminals send for Enter/Backspace/Tab/Escape in legacy mode. Binding a command to `Ctrl+M` no longer fires when the user presses Enter — the named key wins, and `ctrl+<colliding-letter>` matches only when the terminal disambiguates via the Kitty keyboard protocol or `modifyOtherKeys`. ([#1354](https://github.com/can1357/oh-my-pi/issues/1354))
17
+ - Fixed full TUI redraws clearing terminal scrollback with `CSI 3 J`, preserving manual scrollback inspection while active sessions continue updating. ([#1295](https://github.com/can1357/oh-my-pi/issues/1295))
10
18
 
11
19
  ## [15.2.3] - 2026-05-22
12
20
  ### Added
@@ -40,6 +40,11 @@ export interface Focusable {
40
40
  /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
41
41
  focused: boolean;
42
42
  }
43
+ /** Options for scheduling a TUI render. */
44
+ export interface RenderRequestOptions {
45
+ /** Clear terminal scrollback for intentional transcript replacement. */
46
+ clearScrollback?: boolean;
47
+ }
43
48
  /** Type guard to check if a component implements Focusable */
44
49
  export declare function isFocusable(component: Component | null): component is Component & Focusable;
45
50
  /**
@@ -157,5 +162,5 @@ export declare class TUI extends Container {
157
162
  addInputListener(listener: InputListener): () => void;
158
163
  removeInputListener(listener: InputListener): void;
159
164
  stop(): void;
160
- requestRender(force?: boolean): void;
165
+ requestRender(force?: boolean, options?: RenderRequestOptions): void;
161
166
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.3.2",
4
+ "version": "15.4.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "15.3.2",
41
- "@oh-my-pi/pi-utils": "15.3.2",
40
+ "@oh-my-pi/pi-natives": "15.4.1",
41
+ "@oh-my-pi/pi-utils": "15.4.1",
42
42
  "lru-cache": "11.3.6",
43
43
  "marked": "^18.0.3"
44
44
  },
package/src/tui.ts CHANGED
@@ -70,6 +70,12 @@ export interface Focusable {
70
70
  focused: boolean;
71
71
  }
72
72
 
73
+ /** Options for scheduling a TUI render. */
74
+ export interface RenderRequestOptions {
75
+ /** Clear terminal scrollback for intentional transcript replacement. */
76
+ clearScrollback?: boolean;
77
+ }
78
+
73
79
  /** Type guard to check if a component implements Focusable */
74
80
  export function isFocusable(component: Component | null): component is Component & Focusable {
75
81
  return component !== null && "focused" in component;
@@ -129,7 +135,9 @@ function isTermuxSession(): boolean {
129
135
  }
130
136
 
131
137
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
132
- const isMultiplexer = Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
138
+ function isMultiplexerSession(): boolean {
139
+ return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
140
+ }
133
141
 
134
142
  /**
135
143
  * Options for overlay positioning and sizing.
@@ -220,6 +228,31 @@ export class Container implements Component {
220
228
  }
221
229
  }
222
230
 
231
+ /**
232
+ * Render intent. `#planRender` decides which one a frame is, and the
233
+ * corresponding `#emit*` method owns the bytes written and the state update.
234
+ *
235
+ * - `noop`: no content change, only cursor may move.
236
+ * - `initial`: first paint after `start()` — clear viewport, emit transcript.
237
+ * - `sessionReplace`: caller asked for `{ clearScrollback: true }` on a forced
238
+ * render — clear viewport, clear scrollback (outside multiplexers).
239
+ * - `historyRebuild`: width changed and offscreen rows changed — clear viewport
240
+ * and scrollback so terminal history rewraps at the new width.
241
+ * - `viewportRepaint`: rewrite the visible viewport in place. If `appendFrom`
242
+ * is set, emit those tail rows as scrollback growth first so streaming
243
+ * output reaches terminal history before the corrected viewport is drawn.
244
+ * - `shrink`: trailing rows were dropped — clear extras inline.
245
+ * - `diff`: differential repaint of visible rows / append new rows below.
246
+ */
247
+ type RenderIntent =
248
+ | { kind: "noop" }
249
+ | { kind: "initial" }
250
+ | { kind: "sessionReplace" }
251
+ | { kind: "historyRebuild" }
252
+ | { kind: "viewportRepaint"; appendFrom?: number }
253
+ | { kind: "shrink" }
254
+ | { kind: "diff"; firstChanged: number; lastChanged: number; appendedLines: boolean };
255
+
223
256
  /**
224
257
  * TUI - Main class for managing terminal UI with differential rendering
225
258
  */
@@ -248,7 +281,18 @@ export class TUI extends Container {
248
281
  #showHardwareCursor = $flag("PI_HARDWARE_CURSOR");
249
282
  #clearOnShrink = $flag("PI_CLEAR_ON_SHRINK"); // Clear empty rows when content shrinks (default: off)
250
283
  #maxLinesRendered = 0; // Line count from last render, used for viewport calculation
284
+ // Highest count of content rows currently sitting in terminal scrollback
285
+ // above the visible viewport. Used to detect shrink-across-viewport-boundary
286
+ // frames where the new transcript would re-expose rows the terminal has
287
+ // already committed to history — without intervention the rows visibly
288
+ // duplicate once the user scrolls back.
289
+ #scrollbackHighWater = 0;
290
+ // Set after a clear+full replay so the next insert-above-suffix frame does
291
+ // not scroll replayed live chrome (status/editor) into fresh history.
292
+ #suppressNextSuffixScroll = false;
251
293
  #fullRedrawCount = 0;
294
+ #clearScrollbackOnNextRender = false;
295
+ #hasEverRendered = false;
252
296
  #stopped = false;
253
297
 
254
298
  // Overlay stack for modal components rendered on top of base content
@@ -583,8 +627,9 @@ export class TUI extends Container {
583
627
  this.terminal.stop();
584
628
  }
585
629
 
586
- requestRender(force = false): void {
630
+ requestRender(force = false, options?: RenderRequestOptions): void {
587
631
  if (force) {
632
+ this.#clearScrollbackOnNextRender ||= options?.clearScrollback === true;
588
633
  this.#previousLines = [];
589
634
  this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
590
635
  this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
@@ -1024,325 +1069,603 @@ export class TUI extends Container {
1024
1069
  return lines;
1025
1070
  }
1026
1071
 
1072
+ /**
1073
+ * Render one frame. Composes the frame, classifies the intent, and delegates
1074
+ * to the matching emitter. Each emitter owns its bytes and ends with
1075
+ * {@link #commit}, the single state-transition point.
1076
+ */
1027
1077
  #doRender(): void {
1028
1078
  if (this.#stopped) return;
1029
1079
  const width = this.terminal.columns;
1030
1080
  const height = this.terminal.rows;
1031
- let viewportTop = Math.max(0, this.#maxLinesRendered - height);
1032
- let prevViewportTop = this.#viewportTopRow;
1033
- let hardwareCursorRow = this.#hardwareCursorRow;
1034
- const computeLineDiff = (targetRow: number): number => {
1035
- const currentScreenRow = hardwareCursorRow - prevViewportTop;
1036
- const targetScreenRow = targetRow - viewportTop;
1037
- return targetScreenRow - currentScreenRow;
1038
- };
1039
1081
 
1040
- // Render all components to get new lines
1041
- let newLines = this.render(width);
1042
-
1043
- // Composite overlays into the rendered lines (before differential compare)
1082
+ // 1. Compose the frame.
1083
+ let lines = this.render(width);
1044
1084
  if (this.overlayStack.length > 0) {
1045
- newLines = this.#compositeOverlays(newLines, width, height);
1046
- }
1047
-
1048
- // Extract cursor position (marker must be found before diff comparison)
1049
- const cursorPos = this.#extractCursorPosition(newLines, height);
1050
-
1051
- // Terminate every non-image line so #previousLines mirrors emitted bytes
1052
- // (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
1053
- // because the marker is embedded mid-line, and before any diff/full render
1054
- // path so cache comparisons stay byte-accurate.
1055
- newLines = this.#applyLineResets(newLines);
1056
-
1057
- // Width changed - need full re-render (line wrapping changes)
1058
- const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
1059
- const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
1060
-
1061
- // Helper to clear scrollback and viewport and render all new lines
1062
- const fullRender = (clear: boolean): void => {
1063
- this.#fullRedrawCount += 1;
1064
- let buffer = "\x1b[?2026h"; // Begin synchronized output
1065
- // Skip clearing scrollback (3J) in multiplexers — users actively navigate scrollback history
1066
- if (clear) buffer += isMultiplexer ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
1067
- for (let i = 0; i < newLines.length; i++) {
1068
- if (i > 0) buffer += "\r\n";
1069
- // Lines were pre-terminated/normalized by #applyLineResets; image
1070
- // lines were left untouched there.
1071
- buffer += newLines[i];
1072
- }
1073
- this.#cursorRow = Math.max(0, newLines.length - 1);
1074
- const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, this.#cursorRow);
1075
- this.#hardwareCursorRow = toRow;
1076
- buffer += seq;
1077
- buffer += "\x1b[?2026l"; // End synchronized output
1078
- this.terminal.write(buffer);
1079
- // Reset max lines when clearing, otherwise track growth
1080
- if (clear) {
1081
- this.#maxLinesRendered = newLines.length;
1082
- } else {
1083
- this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length);
1084
- }
1085
- this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
1086
- this.#previousLines = newLines;
1087
- this.#previousWidth = width;
1088
- this.#previousHeight = height;
1089
- };
1085
+ lines = this.#compositeOverlays(lines, width, height);
1086
+ }
1087
+ const cursorPos = this.#extractCursorPosition(lines, height);
1088
+ lines = this.#applyLineResets(lines);
1089
+
1090
+ // 2. Capture transition + pre-render state before any emitter runs.
1091
+ const prevViewportTop = this.#viewportTopRow;
1092
+ const prevHardwareCursorRow = this.#hardwareCursorRow;
1093
+ const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width;
1094
+ const heightChanged = this.#previousHeight > 0 && this.#previousHeight !== height;
1095
+
1096
+ // 3. Classify intent.
1097
+ const intent = this.#planRender(lines, widthChanged, heightChanged, prevViewportTop, height);
1098
+ this.#logRedraw(intent, lines.length, height);
1099
+
1100
+ // 4. Execute.
1101
+ switch (intent.kind) {
1102
+ case "noop":
1103
+ this.#writeCursorPosition(cursorPos, lines.length);
1104
+ this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
1105
+ this.#previousWidth = width;
1106
+ this.#previousHeight = height;
1107
+ return;
1108
+ case "initial":
1109
+ this.#emitFullPaint(lines, width, height, cursorPos, { clearViewport: true, clearScrollback: false });
1110
+ this.#hasEverRendered = true;
1111
+ return;
1112
+ case "sessionReplace":
1113
+ this.#clearScrollbackOnNextRender = false;
1114
+ this.#emitFullPaint(lines, width, height, cursorPos, {
1115
+ clearViewport: true,
1116
+ clearScrollback: !isMultiplexerSession(),
1117
+ });
1118
+ return;
1119
+ case "historyRebuild":
1120
+ this.#emitFullPaint(lines, width, height, cursorPos, {
1121
+ clearViewport: true,
1122
+ clearScrollback: !isMultiplexerSession(),
1123
+ });
1124
+ return;
1125
+ case "viewportRepaint":
1126
+ if (intent.appendFrom !== undefined) {
1127
+ this.#emitAppendTail(lines, intent.appendFrom, height, prevViewportTop, prevHardwareCursorRow);
1128
+ }
1129
+ this.#emitViewportRepaint(lines, width, height, cursorPos);
1130
+ return;
1131
+ case "shrink":
1132
+ this.#emitShrink(lines, width, height, cursorPos, prevHardwareCursorRow, prevViewportTop);
1133
+ return;
1134
+ case "diff":
1135
+ this.#emitDiff(
1136
+ lines,
1137
+ width,
1138
+ height,
1139
+ cursorPos,
1140
+ intent.firstChanged,
1141
+ intent.lastChanged,
1142
+ intent.appendedLines,
1143
+ prevViewportTop,
1144
+ prevHardwareCursorRow,
1145
+ );
1146
+ return;
1147
+ }
1148
+ }
1090
1149
 
1091
- const debugRedraw = $flag("PI_DEBUG_REDRAW");
1092
- const logRedraw = (reason: string): void => {
1093
- if (!debugRedraw) return;
1094
- const logPath = getDebugLogPath();
1095
- const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
1096
- fs.appendFileSync(logPath, msg);
1097
- };
1150
+ /**
1151
+ * Map the current frame onto a single render intent. Order matters: forced
1152
+ * resets and session replacement short-circuit before any diff work, and
1153
+ * width-changed-with-offscreen edits route to {@link "historyRebuild"} so
1154
+ * terminal scrollback receives the new geometry.
1155
+ */
1156
+ #planRender(
1157
+ newLines: string[],
1158
+ widthChanged: boolean,
1159
+ heightChanged: boolean,
1160
+ prevViewportTop: number,
1161
+ height: number,
1162
+ ): RenderIntent {
1163
+ // Initial paint after start(): scrollback must keep its prior shell
1164
+ // content, but the viewport must be cleared so stale rows do not bleed
1165
+ // into the new UI.
1166
+ if (!this.#hasEverRendered) return { kind: "initial" };
1167
+
1168
+ // Caller opted into a scrollback wipe via requestRender(true, { clearScrollback: true }).
1169
+ if (this.#clearScrollbackOnNextRender) return { kind: "sessionReplace" };
1170
+
1171
+ // Forced reset (requestRender(true)) without scrollback wipe: previous
1172
+ // lines were dropped, so no diff is possible. Repaint visible rows only
1173
+ // — emitting the transcript here would duplicate it into scrollback.
1174
+ if (this.#previousLines.length === 0) return { kind: "viewportRepaint" };
1175
+
1176
+ const diff = this.#diffLines(newLines);
1177
+
1178
+ // Shrink-across-viewport-boundary: if a shrink would place the new
1179
+ // viewport above rows already committed to terminal scrollback, those
1180
+ // rows would appear twice when the user scrolls back. A clear+replay
1181
+ // keeps the current OMP transcript scrollable while dropping stale
1182
+ // terminal history.
1183
+ const naturalViewportTop = Math.max(0, newLines.length - height);
1184
+ if (
1185
+ diff.firstChanged !== -1 &&
1186
+ newLines.length < this.#previousLines.length &&
1187
+ naturalViewportTop < this.#scrollbackHighWater &&
1188
+ !isMultiplexerSession()
1189
+ ) {
1190
+ return { kind: "historyRebuild" };
1191
+ }
1098
1192
 
1099
- // First render - just output everything without clearing (assumes clean screen)
1100
- if (this.#previousLines.length === 0 && !widthChanged && !heightChanged) {
1101
- logRedraw("first render");
1102
- fullRender(false);
1103
- return;
1193
+ const suppressSuffixScroll = this.#suppressNextSuffixScroll;
1194
+ this.#suppressNextSuffixScroll = false;
1195
+ if (
1196
+ suppressSuffixScroll &&
1197
+ diff.appendedLines &&
1198
+ diff.firstChanged < this.#previousLines.length &&
1199
+ !isMultiplexerSession()
1200
+ ) {
1201
+ return { kind: "viewportRepaint" };
1202
+ }
1203
+
1204
+ if (diff.firstChanged === -1) {
1205
+ // Content unchanged. Width change still alters wrapping geometry;
1206
+ // height change shifts the visible window. Either needs a repaint
1207
+ // (outside hostile environments).
1208
+ if (widthChanged) return { kind: "viewportRepaint" };
1209
+ if (heightChanged && !isTermuxSession() && !isMultiplexerSession()) return { kind: "viewportRepaint" };
1210
+ return { kind: "noop" };
1104
1211
  }
1105
1212
 
1106
- // Width changes always need a full re-render because wrapping changes.
1213
+ // Width changes alter wrapping for the whole transcript. Offscreen
1214
+ // edits need a history rebuild so terminal scrollback receives the
1215
+ // new geometry; pure appends fall through to the diff path so the
1216
+ // append handler scrolls them into scrollback correctly.
1107
1217
  if (widthChanged) {
1108
- logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
1109
- fullRender(true);
1110
- return;
1218
+ if (diff.firstChanged < prevViewportTop) return { kind: "historyRebuild" };
1219
+ const pureAppend = diff.appendedLines && diff.firstChanged === this.#previousLines.length;
1220
+ if (!pureAppend) return { kind: "viewportRepaint" };
1111
1221
  }
1112
1222
 
1113
- // Height changes normally need a full re-render to keep the visible viewport aligned,
1114
- // but Termux changes height when the software keyboard shows or hides.
1115
- // In that environment, a full redraw causes the entire history to replay on every toggle.
1116
- if (heightChanged && !isTermuxSession() && !isMultiplexer) {
1117
- logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
1118
- fullRender(true);
1119
- return;
1223
+ const contentGrew = newLines.length > this.#previousLines.length;
1224
+
1225
+ // Height changes shift the visible window. Repaint when content didn't
1226
+ // grow, but skip in Termux (software keyboard toggles height) and inside
1227
+ // multiplexers (panes manage their own redraws).
1228
+ if (heightChanged && !contentGrew && !isTermuxSession() && !isMultiplexerSession()) {
1229
+ return { kind: "viewportRepaint" };
1120
1230
  }
1121
1231
 
1122
- // Content shrunk below the previous render and no overlays - re-render to clear empty rows
1123
- // (overlays need the padding, so only do this when no overlays are active)
1124
- // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1232
+ // Configurable shrink-clear: opt-in path that repaints to wipe rows the
1233
+ // diff path would leave behind.
1125
1234
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
1126
- logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1127
- fullRender(true);
1128
- return;
1235
+ return { kind: "viewportRepaint" };
1129
1236
  }
1130
1237
 
1131
- // Find first and last changed lines
1238
+ // Pure trailing shrink: all changed indices live past the new tail.
1239
+ if (diff.firstChanged >= newLines.length) {
1240
+ return { kind: "shrink" };
1241
+ }
1242
+
1243
+ // Offscreen edit: viewport repaint corrects the shifted rows. If new
1244
+ // rows also appended in the same frame, emit them as scrollback growth
1245
+ // first so streaming output is not lost from terminal history.
1246
+ if (diff.firstChanged < prevViewportTop) {
1247
+ const appendFrom = diff.appendedLines ? this.#findAppendedTailStart(newLines) : undefined;
1248
+ return { kind: "viewportRepaint", appendFrom };
1249
+ }
1250
+
1251
+ return {
1252
+ kind: "diff",
1253
+ firstChanged: diff.firstChanged,
1254
+ lastChanged: diff.lastChanged,
1255
+ appendedLines: diff.appendedLines,
1256
+ };
1257
+ }
1258
+
1259
+ /**
1260
+ * Two-pointer diff over `#previousLines` and `newLines`. `firstChanged` is
1261
+ * `-1` when the two are identical; otherwise it is the first differing
1262
+ * index. Trailing appends are normalized so `lastChanged` always ends at the
1263
+ * last row that needs to be touched.
1264
+ */
1265
+ #diffLines(newLines: string[]): { firstChanged: number; lastChanged: number; appendedLines: boolean } {
1132
1266
  let firstChanged = -1;
1133
1267
  let lastChanged = -1;
1134
1268
  const maxLines = Math.max(newLines.length, this.#previousLines.length);
1135
1269
  for (let i = 0; i < maxLines; i++) {
1136
1270
  const oldLine = i < this.#previousLines.length ? this.#previousLines[i] : "";
1137
1271
  const newLine = i < newLines.length ? newLines[i] : "";
1138
-
1139
1272
  if (oldLine !== newLine) {
1140
- if (firstChanged === -1) {
1141
- firstChanged = i;
1142
- }
1273
+ if (firstChanged === -1) firstChanged = i;
1143
1274
  lastChanged = i;
1144
1275
  }
1145
1276
  }
1146
1277
  const appendedLines = newLines.length > this.#previousLines.length;
1147
1278
  if (appendedLines) {
1148
- if (firstChanged === -1) {
1149
- firstChanged = this.#previousLines.length;
1150
- }
1279
+ if (firstChanged === -1) firstChanged = this.#previousLines.length;
1151
1280
  lastChanged = newLines.length - 1;
1152
1281
  }
1153
- const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
1282
+ return { firstChanged, lastChanged, appendedLines };
1283
+ }
1154
1284
 
1155
- // No changes - but still need to update hardware cursor position if it moved
1156
- if (firstChanged === -1) {
1157
- this.#writeCursorPosition(cursorPos, newLines.length);
1158
- this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
1159
- return;
1285
+ /**
1286
+ * Locate the longest suffix of `#previousLines` that appears in `newLines`.
1287
+ * The returned index is the first row past that suffix — the rows that are
1288
+ * "new appends" relative to the unchanged tail. Used to push streaming
1289
+ * output into scrollback even when an offscreen edit also moved rows.
1290
+ */
1291
+ #findAppendedTailStart(newLines: string[]): number {
1292
+ if (this.#previousLines.length === 0) return newLines.length;
1293
+ const previousLast = this.#previousLines[this.#previousLines.length - 1];
1294
+ let bestEnd = -1;
1295
+ let bestLength = 0;
1296
+ for (let end = newLines.length - 1; end >= 0; end--) {
1297
+ if (newLines[end] !== previousLast) continue;
1298
+ let length = 1;
1299
+ while (
1300
+ length < this.#previousLines.length &&
1301
+ end - length >= 0 &&
1302
+ this.#previousLines[this.#previousLines.length - 1 - length] === newLines[end - length]
1303
+ ) {
1304
+ length += 1;
1305
+ }
1306
+ if (length > bestLength) {
1307
+ bestLength = length;
1308
+ bestEnd = end;
1309
+ }
1160
1310
  }
1311
+ return bestEnd === -1 ? newLines.length : bestEnd + 1;
1312
+ }
1161
1313
 
1162
- // All changes are in deleted lines (nothing to render, just clear)
1163
- if (firstChanged >= newLines.length) {
1164
- if (this.#previousLines.length > newLines.length) {
1165
- let buffer = "\x1b[?2026h";
1166
- // Move to end of new content (clamp to 0 for empty content)
1167
- const targetRow = Math.max(0, newLines.length - 1);
1168
- const lineDiff = computeLineDiff(targetRow);
1169
- if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
1170
- else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
1171
- buffer += "\r";
1172
- // Clear extra lines without scrolling
1173
- const extraLines = this.#previousLines.length - newLines.length;
1174
- if (extraLines > height) {
1175
- logRedraw(`extraLines > height (${extraLines} > ${height})`);
1176
- fullRender(true);
1177
- return;
1178
- }
1179
- const clearStartOffset = newLines.length > 0 && extraLines > 0 ? 1 : 0;
1180
- if (clearStartOffset > 0) {
1181
- buffer += `\x1b[${clearStartOffset}B`;
1182
- }
1183
- for (let i = 0; i < extraLines; i++) {
1184
- buffer += "\r\x1b[2K";
1185
- if (i < extraLines - 1) buffer += "\x1b[1B";
1186
- }
1187
- const moveUp = extraLines - 1 + clearStartOffset;
1188
- if (moveUp > 0) {
1189
- buffer += `\x1b[${moveUp}A`;
1190
- }
1191
- this.#cursorRow = targetRow;
1192
- const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, targetRow);
1193
- this.#hardwareCursorRow = toRow;
1194
- buffer += seq;
1195
- buffer += "\x1b[?2026l";
1196
- this.terminal.write(buffer);
1197
- }
1198
- this.#previousLines = newLines;
1199
- this.#previousWidth = width;
1200
- this.#previousHeight = height;
1201
- this.#maxLinesRendered = newLines.length;
1202
- this.#viewportTopRow = Math.max(0, newLines.length - height);
1203
- return;
1314
+ /**
1315
+ * Truncate a line to the visible viewport width. Image lines are left
1316
+ * alone, narrow lines pass through unchanged. Truncation re-appends the
1317
+ * per-line terminator so SGR/OSC 8 state does not leak across rows when
1318
+ * `truncateToWidth` drops the trailing bytes appended by
1319
+ * {@link #applyLineResets}.
1320
+ */
1321
+ #fitLineToWidth(line: string, width: number): string {
1322
+ if (TERMINAL.isImageLine(line)) return line;
1323
+ if (visibleWidth(line) <= width) return line;
1324
+ const truncated = truncateToWidth(line, width, Ellipsis.Omit);
1325
+ return truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
1326
+ }
1327
+
1328
+ /**
1329
+ * Single state-transition point. Every emitter calls this exactly once at
1330
+ * the end so cursor/viewport/scrollback accounting stays consistent.
1331
+ */
1332
+ #commit(lines: string[], width: number, height: number, viewportTop: number, hardwareCursorRow: number): void {
1333
+ this.#previousLines = lines;
1334
+ this.#previousWidth = width;
1335
+ this.#previousHeight = height;
1336
+ this.#cursorRow = Math.max(0, lines.length - 1);
1337
+ this.#viewportTopRow = viewportTop;
1338
+ this.#hardwareCursorRow = hardwareCursorRow;
1339
+ }
1340
+
1341
+ /**
1342
+ * Clear the viewport (optionally scrollback) and emit the full transcript.
1343
+ * Backs `initial`, `sessionReplace`, and `historyRebuild` intents.
1344
+ */
1345
+ #emitFullPaint(
1346
+ lines: string[],
1347
+ width: number,
1348
+ height: number,
1349
+ cursorPos: { row: number; col: number } | null,
1350
+ options: { clearViewport: boolean; clearScrollback: boolean },
1351
+ ): void {
1352
+ this.#fullRedrawCount += 1;
1353
+ let buffer = "\x1b[?2026h";
1354
+ if (options.clearViewport) {
1355
+ buffer += options.clearScrollback ? "\x1b[2J\x1b[H\x1b[3J" : "\x1b[2J\x1b[H";
1204
1356
  }
1357
+ for (let i = 0; i < lines.length; i++) {
1358
+ if (i > 0) buffer += "\r\n";
1359
+ buffer += lines[i];
1360
+ }
1361
+ const finalRow = Math.max(0, lines.length - 1);
1362
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
1363
+ buffer += seq;
1364
+ buffer += "\x1b[?2026l";
1365
+ this.terminal.write(buffer);
1205
1366
 
1206
- // Differential rendering can only touch what was actually visible.
1207
- // Any change above the previous viewport requires a full redraw so terminal
1208
- // scrollback ends up consistent with the new transcript state.
1209
- if (firstChanged < prevViewportTop) {
1210
- logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1211
- fullRender(true);
1367
+ this.#maxLinesRendered = options.clearViewport ? lines.length : Math.max(this.#maxLinesRendered, lines.length);
1368
+ if (options.clearScrollback) {
1369
+ this.#scrollbackHighWater = 0;
1370
+ this.#suppressNextSuffixScroll = lines.length > height;
1371
+ }
1372
+ const pushedNow = Math.max(0, lines.length - height);
1373
+ if (pushedNow > this.#scrollbackHighWater) {
1374
+ this.#scrollbackHighWater = pushedNow;
1375
+ }
1376
+ this.#commit(lines, width, height, Math.max(0, this.#maxLinesRendered - height), toRow);
1377
+ }
1378
+
1379
+ /**
1380
+ * Rewrite the visible viewport in place. Cursor home, clear each row,
1381
+ * emit the bottom-anchored slice of `lines`. No scrollback growth.
1382
+ */
1383
+ #emitViewportRepaint(
1384
+ lines: string[],
1385
+ width: number,
1386
+ height: number,
1387
+ cursorPos: { row: number; col: number } | null,
1388
+ ): void {
1389
+ this.#fullRedrawCount += 1;
1390
+ const viewportTop = Math.max(0, lines.length - height);
1391
+ let buffer = "\x1b[?2026h\x1b[H";
1392
+ for (let screenRow = 0; screenRow < height; screenRow++) {
1393
+ if (screenRow > 0) buffer += "\r\n";
1394
+ buffer += "\x1b[2K";
1395
+ const line = lines[viewportTop + screenRow] ?? "";
1396
+ buffer += this.#fitLineToWidth(line, width);
1397
+ }
1398
+ // The loop unconditionally writes `height` rows from screen row 0, so the
1399
+ // hardware cursor lands at screen row `height - 1` regardless of how many
1400
+ // of those rows held actual content. Tracking it as `lines.length - 1`
1401
+ // when the content is shorter than the viewport makes the relative
1402
+ // `rowDelta` math in `#cursorControlSequence` underestimate the upward
1403
+ // move and the IME cursor stays pinned to the viewport bottom on
1404
+ // height-grow resizes.
1405
+ const finalRow = viewportTop + height - 1;
1406
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalRow);
1407
+ buffer += seq;
1408
+ buffer += "\x1b[?2026l";
1409
+ this.terminal.write(buffer);
1410
+
1411
+ this.#maxLinesRendered = lines.length;
1412
+ this.#commit(lines, width, height, viewportTop, toRow);
1413
+ }
1414
+
1415
+ /**
1416
+ * Push the appended tail into terminal scrollback by `\r\n`-ing past the
1417
+ * previous viewport bottom. Used as a prefix to {@link #emitViewportRepaint}
1418
+ * when an offscreen edit and an append land in the same frame; does not
1419
+ * call {@link #commit} (the following repaint owns final state).
1420
+ */
1421
+ #emitAppendTail(
1422
+ lines: string[],
1423
+ start: number,
1424
+ height: number,
1425
+ prevViewportTop: number,
1426
+ prevHardwareCursorRow: number,
1427
+ ): void {
1428
+ if (start >= lines.length) return;
1429
+ let buffer = "\x1b[?2026h";
1430
+ // Clamp tracked cursor to the visible viewport bottom — terminals clamp
1431
+ // on resize, so a prior frame may have committed a row that no longer
1432
+ // exists. Without this the scroll math points outside the viewport.
1433
+ const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
1434
+ const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
1435
+ const moveToBottom = height - 1 - currentScreenRow;
1436
+ if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
1437
+ for (let i = start; i < lines.length; i++) {
1438
+ buffer += "\r\n";
1439
+ buffer += lines[i];
1440
+ }
1441
+ buffer += "\x1b[?2026l";
1442
+ this.terminal.write(buffer);
1443
+ const pushedNow = Math.max(0, lines.length - height);
1444
+ if (pushedNow > this.#scrollbackHighWater) {
1445
+ this.#scrollbackHighWater = pushedNow;
1446
+ }
1447
+ }
1448
+
1449
+ /**
1450
+ * Trailing-shrink: prior content shared a prefix with the new content; the
1451
+ * extra rows below the new tail need to be cleared without scrolling. Falls
1452
+ * back to {@link #emitViewportRepaint} when more rows must be cleared than
1453
+ * fit on screen.
1454
+ */
1455
+ #emitShrink(
1456
+ lines: string[],
1457
+ width: number,
1458
+ height: number,
1459
+ cursorPos: { row: number; col: number } | null,
1460
+ prevHardwareCursorRow: number,
1461
+ prevViewportTop: number,
1462
+ ): void {
1463
+ const extraLines = this.#previousLines.length - lines.length;
1464
+ if (extraLines <= 0) {
1465
+ this.#commit(lines, width, height, Math.max(0, lines.length - height), prevHardwareCursorRow);
1466
+ this.#maxLinesRendered = lines.length;
1212
1467
  return;
1213
1468
  }
1469
+ if (extraLines > height) {
1470
+ this.#emitViewportRepaint(lines, width, height, cursorPos);
1471
+ return;
1472
+ }
1473
+
1474
+ const viewportTop = Math.max(0, this.#maxLinesRendered - height);
1475
+ const targetRow = Math.max(0, lines.length - 1);
1476
+
1477
+ let buffer = "\x1b[?2026h";
1214
1478
 
1215
- // Render from first changed line to end
1216
- // Build buffer with all updates wrapped in synchronized output
1217
- let buffer = "\x1b[?2026h"; // Begin synchronized output
1218
- const prevViewportBottom = prevViewportTop + height - 1;
1479
+ const clampedCursor = Math.min(prevHardwareCursorRow, prevViewportTop + height - 1);
1480
+ const currentScreenRow = clampedCursor - prevViewportTop;
1481
+ const targetScreenRow = targetRow - viewportTop;
1482
+ const lineDiff = targetScreenRow - currentScreenRow;
1483
+ if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
1484
+ else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
1485
+ buffer += "\r";
1486
+
1487
+ const clearStartOffset = lines.length > 0 ? 1 : 0;
1488
+ if (clearStartOffset > 0) {
1489
+ buffer += `\x1b[${clearStartOffset}B`;
1490
+ }
1491
+ for (let i = 0; i < extraLines; i++) {
1492
+ buffer += "\r\x1b[2K";
1493
+ if (i < extraLines - 1) buffer += "\x1b[1B";
1494
+ }
1495
+ const moveUp = extraLines - 1 + clearStartOffset;
1496
+ if (moveUp > 0) {
1497
+ buffer += `\x1b[${moveUp}A`;
1498
+ }
1499
+
1500
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, targetRow);
1501
+ buffer += seq;
1502
+ buffer += "\x1b[?2026l";
1503
+ this.terminal.write(buffer);
1504
+
1505
+ this.#maxLinesRendered = lines.length;
1506
+ this.#commit(lines, width, height, Math.max(0, lines.length - height), toRow);
1507
+ }
1508
+
1509
+ /**
1510
+ * Differential rewrite from `firstChanged` through `lastChanged`. Handles
1511
+ * three sub-shapes: pure append below the prior viewport (scroll + write),
1512
+ * in-place replace of visible rows, and replace-plus-trailing-shrink (clear
1513
+ * extras after writing). Cursor math is local to this method.
1514
+ */
1515
+ #emitDiff(
1516
+ lines: string[],
1517
+ width: number,
1518
+ height: number,
1519
+ cursorPos: { row: number; col: number } | null,
1520
+ firstChanged: number,
1521
+ lastChanged: number,
1522
+ appendedLines: boolean,
1523
+ prevViewportTop: number,
1524
+ prevHardwareCursorRow: number,
1525
+ ): void {
1526
+ let viewportTop = Math.max(0, this.#maxLinesRendered - height);
1527
+ let activeViewportTop = prevViewportTop;
1528
+ // Terminals clamp the hardware cursor to the visible viewport on resize.
1529
+ // If our tracked row is past the viewport bottom, the real cursor was
1530
+ // clamped; clamp our tracking to match so relative moves land correctly.
1531
+ let hardwareCursorRow = Math.min(prevHardwareCursorRow, activeViewportTop + height - 1);
1532
+
1533
+ const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
1219
1534
  const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
1535
+
1536
+ let buffer = "\x1b[?2026h";
1537
+
1538
+ // Scroll-down branch: target row is past the bottom of the previous
1539
+ // viewport (a pure append). Emit `\r\n`s so the terminal pushes the
1540
+ // existing viewport into scrollback before we start writing.
1541
+ const prevViewportBottom = activeViewportTop + height - 1;
1220
1542
  if (moveTargetRow > prevViewportBottom) {
1221
- const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
1543
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - activeViewportTop));
1222
1544
  const moveToBottom = height - 1 - currentScreenRow;
1223
- if (moveToBottom > 0) {
1224
- buffer += `\x1b[${moveToBottom}B`;
1225
- }
1545
+ if (moveToBottom > 0) buffer += `\x1b[${moveToBottom}B`;
1226
1546
  const scroll = moveTargetRow - prevViewportBottom;
1227
1547
  buffer += "\r\n".repeat(scroll);
1228
- prevViewportTop += scroll;
1548
+ activeViewportTop += scroll;
1229
1549
  viewportTop += scroll;
1230
1550
  hardwareCursorRow = moveTargetRow;
1231
1551
  }
1232
1552
 
1233
- // Move cursor to first changed line (use hardwareCursorRow for actual position)
1234
- const lineDiff = computeLineDiff(moveTargetRow);
1235
- if (lineDiff > 0) {
1236
- buffer += `\x1b[${lineDiff}B`; // Move down
1237
- } else if (lineDiff < 0) {
1238
- buffer += `\x1b[${-lineDiff}A`; // Move up
1239
- }
1240
-
1241
- buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
1242
-
1243
- // Only render changed lines (firstChanged to lastChanged), not all lines to end
1244
- // This reduces flicker when only a single line changes (e.g., spinner animation)
1245
- const renderEnd = Math.min(lastChanged, newLines.length - 1);
1553
+ // Position cursor at the row we need to start writing from.
1554
+ const currentScreenRow = hardwareCursorRow - activeViewportTop;
1555
+ const targetScreenRow = moveTargetRow - viewportTop;
1556
+ const lineDiff = targetScreenRow - currentScreenRow;
1557
+ if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
1558
+ else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
1559
+ buffer += appendStart ? "\r\n" : "\r";
1560
+
1561
+ // Repaint only firstChanged..lastChanged, not all rows to the end.
1562
+ // This bounds flicker for single-row updates (e.g. spinner ticks).
1563
+ const renderEnd = Math.min(lastChanged, lines.length - 1);
1246
1564
  for (let i = firstChanged; i <= renderEnd; i++) {
1247
1565
  if (i > firstChanged) buffer += "\r\n";
1248
- buffer += "\x1b[2K"; // Clear current line
1249
- const line = newLines[i];
1250
- let truncatedLine = line;
1251
- const isImage = TERMINAL.isImageLine(line);
1252
- if (!isImage && visibleWidth(line) > width) {
1253
- if (debugRedraw) {
1254
- const debugData = [
1255
- `[TUI Truncate] ${new Date().toISOString()}`,
1256
- `Line ${i} truncated: ${visibleWidth(line)} > ${width}`,
1257
- `Content preview: ${line.slice(0, 100)}...`,
1258
- "",
1259
- ].join("\n");
1260
- try {
1261
- fs.appendFileSync(getDebugLogPath(), debugData);
1262
- } catch {
1263
- // Ignore write errors - truncation should still work
1264
- }
1265
- }
1266
- truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1267
- // Re-append the terminator: truncateToWidth removes trailing
1268
- // content past the visible-width budget, which may also drop the
1269
- // terminator appended by #applyLineResets. Match the conditional
1270
- // OSC 8 close strategy used there.
1271
- truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1272
- }
1273
- // Non-image lines are pre-terminated/normalized by #applyLineResets;
1274
- // truncated lines re-append LINE_TERMINATOR above.
1275
- buffer += truncatedLine;
1566
+ buffer += "\x1b[2K";
1567
+ buffer += this.#fitLineToWidth(lines[i], width);
1276
1568
  }
1277
1569
 
1278
- // Track where cursor ended up after rendering
1570
+ // If the prior frame was taller, clear the trailing rows.
1279
1571
  let finalCursorRow = renderEnd;
1280
-
1281
- // If we had more lines before, clear them and move cursor back
1282
- if (this.#previousLines.length > newLines.length) {
1283
- // Move to end of new content first if we stopped before it
1284
- if (renderEnd < newLines.length - 1) {
1285
- const moveDown = newLines.length - 1 - renderEnd;
1572
+ if (this.#previousLines.length > lines.length) {
1573
+ if (renderEnd < lines.length - 1) {
1574
+ const moveDown = lines.length - 1 - renderEnd;
1286
1575
  buffer += `\x1b[${moveDown}B`;
1287
- finalCursorRow = newLines.length - 1;
1576
+ finalCursorRow = lines.length - 1;
1288
1577
  }
1289
- const extraLines = this.#previousLines.length - newLines.length;
1290
- for (let i = newLines.length; i < this.#previousLines.length; i++) {
1578
+ const extraLines = this.#previousLines.length - lines.length;
1579
+ for (let i = lines.length; i < this.#previousLines.length; i++) {
1291
1580
  buffer += "\r\n\x1b[2K";
1292
1581
  }
1293
- // Move cursor back to end of new content
1294
1582
  buffer += `\x1b[${extraLines}A`;
1295
1583
  }
1296
1584
 
1297
- const { seq, toRow } = this.#cursorControlSequence(cursorPos, newLines.length, finalCursorRow);
1298
- this.#hardwareCursorRow = toRow;
1585
+ const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
1299
1586
  buffer += seq;
1300
- buffer += "\x1b[?2026l"; // End synchronized output
1301
-
1302
- if ($flag("PI_TUI_DEBUG")) {
1303
- const debugDir = "/tmp/tui";
1304
- fs.mkdirSync(debugDir, { recursive: true });
1305
- const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
1306
- const debugData = [
1307
- `firstChanged: ${firstChanged}`,
1308
- `viewportTop: ${viewportTop}`,
1309
- `cursorRow: ${this.#cursorRow}`,
1310
- `height: ${height}`,
1311
- `lineDiff: ${lineDiff}`,
1312
- `hardwareCursorRow: ${hardwareCursorRow}`,
1313
- `hardwareCursorRow (post): ${this.#hardwareCursorRow}`,
1314
- `renderEnd: ${renderEnd}`,
1315
- `finalCursorRow: ${finalCursorRow}`,
1316
- `cursorPos: ${JSON.stringify(cursorPos)}`,
1317
- `newLines.length: ${newLines.length}`,
1318
- `previousLines.length: ${this.#previousLines.length}`,
1319
- "",
1320
- "=== newLines ===",
1321
- JSON.stringify(newLines, null, 2),
1322
- "",
1323
- "=== previousLines ===",
1324
- JSON.stringify(this.#previousLines, null, 2),
1325
- "",
1326
- "=== buffer ===",
1327
- JSON.stringify(buffer),
1328
- ].join("\n");
1329
- fs.writeFileSync(debugPath, debugData);
1330
- }
1331
-
1332
- // Write entire buffer at once
1587
+ buffer += "\x1b[?2026l";
1588
+
1589
+ this.#writeDiffDebug(
1590
+ lines,
1591
+ firstChanged,
1592
+ viewportTop,
1593
+ height,
1594
+ lineDiff,
1595
+ hardwareCursorRow,
1596
+ renderEnd,
1597
+ finalCursorRow,
1598
+ cursorPos,
1599
+ toRow,
1600
+ buffer,
1601
+ );
1333
1602
  this.terminal.write(buffer);
1334
1603
 
1335
- // Track cursor position for next render.
1336
- // cursorRow tracks end of content (for viewport calculation).
1337
- // #hardwareCursorRow was already updated by #cursorControlSequence above.
1338
- this.#cursorRow = Math.max(0, newLines.length - 1);
1339
- // Track content height for viewport calculation
1340
- this.#maxLinesRendered = newLines.length;
1341
- this.#viewportTopRow = Math.max(0, newLines.length - height);
1604
+ this.#maxLinesRendered = lines.length;
1605
+ if (lines.length > this.#previousLines.length) {
1606
+ const pushedNow = Math.max(0, lines.length - height);
1607
+ if (pushedNow > this.#scrollbackHighWater) {
1608
+ this.#scrollbackHighWater = pushedNow;
1609
+ }
1610
+ }
1611
+ this.#commit(lines, width, height, Math.max(0, lines.length - height), toRow);
1612
+ }
1342
1613
 
1343
- this.#previousLines = newLines;
1344
- this.#previousWidth = width;
1345
- this.#previousHeight = height;
1614
+ /** Optional intent log under PI_DEBUG_REDRAW. */
1615
+ #logRedraw(intent: RenderIntent, newLength: number, height: number): void {
1616
+ if (!$flag("PI_DEBUG_REDRAW")) return;
1617
+ const detail =
1618
+ intent.kind === "diff"
1619
+ ? `${intent.kind}(first=${intent.firstChanged}, last=${intent.lastChanged}, appended=${intent.appendedLines})`
1620
+ : intent.kind === "viewportRepaint" && intent.appendFrom !== undefined
1621
+ ? `${intent.kind}(appendFrom=${intent.appendFrom})`
1622
+ : intent.kind;
1623
+ const msg = `[${new Date().toISOString()}] render: ${detail} (prev=${this.#previousLines.length}, new=${newLength}, height=${height})\n`;
1624
+ fs.appendFileSync(getDebugLogPath(), msg);
1625
+ }
1626
+
1627
+ /** Optional per-render dump under PI_TUI_DEBUG; isolated so #emitDiff stays readable. */
1628
+ #writeDiffDebug(
1629
+ lines: string[],
1630
+ firstChanged: number,
1631
+ viewportTop: number,
1632
+ height: number,
1633
+ lineDiff: number,
1634
+ hardwareCursorRow: number,
1635
+ renderEnd: number,
1636
+ finalCursorRow: number,
1637
+ cursorPos: { row: number; col: number } | null,
1638
+ toRow: number,
1639
+ buffer: string,
1640
+ ): void {
1641
+ if (!$flag("PI_TUI_DEBUG")) return;
1642
+ const debugDir = "/tmp/tui";
1643
+ fs.mkdirSync(debugDir, { recursive: true });
1644
+ const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
1645
+ const debugData = [
1646
+ `firstChanged: ${firstChanged}`,
1647
+ `viewportTop: ${viewportTop}`,
1648
+ `cursorRow: ${this.#cursorRow}`,
1649
+ `height: ${height}`,
1650
+ `lineDiff: ${lineDiff}`,
1651
+ `hardwareCursorRow: ${hardwareCursorRow}`,
1652
+ `hardwareCursorRow (post): ${toRow}`,
1653
+ `renderEnd: ${renderEnd}`,
1654
+ `finalCursorRow: ${finalCursorRow}`,
1655
+ `cursorPos: ${JSON.stringify(cursorPos)}`,
1656
+ `newLines.length: ${lines.length}`,
1657
+ `previousLines.length: ${this.#previousLines.length}`,
1658
+ "",
1659
+ "=== newLines ===",
1660
+ JSON.stringify(lines, null, 2),
1661
+ "",
1662
+ "=== previousLines ===",
1663
+ JSON.stringify(this.#previousLines, null, 2),
1664
+ "",
1665
+ "=== buffer ===",
1666
+ JSON.stringify(buffer),
1667
+ ].join("\n");
1668
+ fs.writeFileSync(debugPath, debugData);
1346
1669
  }
1347
1670
 
1348
1671
  /**