@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 +3 -3
- package/src/components/select-list.ts +16 -7
- package/src/tui.ts +131 -298
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.
|
|
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.
|
|
37
|
-
"@oh-my-pi/pi-utils": "13.
|
|
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 =
|
|
90
|
+
const displayValue = labelText;
|
|
82
91
|
|
|
83
|
-
if (
|
|
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(
|
|
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 =
|
|
117
|
+
const displayValue = labelText;
|
|
109
118
|
const prefix = padding(visibleWidth(this.theme.symbols.cursor) + 1);
|
|
110
119
|
|
|
111
|
-
if (
|
|
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(
|
|
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; //
|
|
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
|
|
542
|
+
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
545
543
|
if (this.#previousLines.length > 0) {
|
|
546
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
1025
|
-
|
|
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 =
|
|
1052
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1064
|
+
fullRender(true);
|
|
1150
1065
|
return;
|
|
1151
1066
|
}
|
|
1152
1067
|
|
|
1153
|
-
//
|
|
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
|
|
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 =
|
|
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,
|
|
1204
|
-
this.#viewportTopRow =
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
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.#
|
|
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 =
|
|
1264
|
-
if (
|
|
1265
|
-
// First change is above previous viewport -
|
|
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
|
-
|
|
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
|
|
1275
|
-
const
|
|
1276
|
-
if (
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1218
|
+
// If we had more lines before, clear them and move cursor back
|
|
1370
1219
|
if (this.#previousLines.length > newLines.length) {
|
|
1371
|
-
if
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
|
1270
|
+
// hardwareCursorRow tracks actual terminal cursor position (for movement)
|
|
1426
1271
|
this.#cursorRow = Math.max(0, newLines.length - 1);
|
|
1427
|
-
this.#hardwareCursorRow =
|
|
1428
|
-
|
|
1429
|
-
this.#
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
-
*
|
|
1443
|
-
*
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
1479
|
-
this.#
|
|
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
|
}
|