@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 +8 -0
- package/dist/types/tui.d.ts +6 -1
- package/package.json +3 -3
- package/src/tui.ts +579 -256
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
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.
|
|
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
|
-
|
|
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
|
-
//
|
|
1041
|
-
let
|
|
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
|
-
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
|
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
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
1114
|
-
|
|
1115
|
-
//
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
//
|
|
1123
|
-
//
|
|
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
|
-
|
|
1127
|
-
fullRender(true);
|
|
1128
|
-
return;
|
|
1235
|
+
return { kind: "viewportRepaint" };
|
|
1129
1236
|
}
|
|
1130
1237
|
|
|
1131
|
-
//
|
|
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
|
-
|
|
1282
|
+
return { firstChanged, lastChanged, appendedLines };
|
|
1283
|
+
}
|
|
1154
1284
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
const
|
|
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 -
|
|
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
|
-
|
|
1548
|
+
activeViewportTop += scroll;
|
|
1229
1549
|
viewportTop += scroll;
|
|
1230
1550
|
hardwareCursorRow = moveTargetRow;
|
|
1231
1551
|
}
|
|
1232
1552
|
|
|
1233
|
-
//
|
|
1234
|
-
const
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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";
|
|
1249
|
-
|
|
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
|
-
//
|
|
1570
|
+
// If the prior frame was taller, clear the trailing rows.
|
|
1279
1571
|
let finalCursorRow = renderEnd;
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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 =
|
|
1576
|
+
finalCursorRow = lines.length - 1;
|
|
1288
1577
|
}
|
|
1289
|
-
const extraLines = this.#previousLines.length -
|
|
1290
|
-
for (let 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,
|
|
1298
|
-
this.#hardwareCursorRow = toRow;
|
|
1585
|
+
const { seq, toRow } = this.#cursorControlSequence(cursorPos, lines.length, finalCursorRow);
|
|
1299
1586
|
buffer += seq;
|
|
1300
|
-
buffer += "\x1b[?2026l";
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
/**
|