linecraft 0.2.0 → 0.2.2
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/LICENSE +0 -1
- package/README.md +34 -8
- package/lib/component.d.ts +34 -0
- package/lib/component.d.ts.map +1 -0
- package/lib/component.js +42 -0
- package/lib/component.js.map +1 -0
- package/lib/components/code-debug.d.ts +35 -0
- package/lib/components/code-debug.d.ts.map +1 -0
- package/lib/components/code-debug.js +294 -0
- package/lib/components/code-debug.js.map +1 -0
- package/lib/components/fill.d.ts +15 -0
- package/lib/components/fill.d.ts.map +1 -0
- package/lib/components/fill.js +37 -0
- package/lib/components/fill.js.map +1 -0
- package/lib/components/index.d.ts +2 -2
- package/lib/components/index.d.ts.map +1 -1
- package/lib/components/index.js +2 -2
- package/lib/components/index.js.map +1 -1
- package/lib/components/progress-bar-grid.d.ts +1 -1
- package/lib/components/progress-bar-grid.d.ts.map +1 -1
- package/lib/components/progress-bar-grid.js +6 -6
- package/lib/components/progress-bar-grid.js.map +1 -1
- package/lib/components/prompt.d.ts +4 -5
- package/lib/components/prompt.d.ts.map +1 -1
- package/lib/components/prompt.js +17 -69
- package/lib/components/prompt.js.map +1 -1
- package/lib/components/section.d.ts +33 -0
- package/lib/components/section.d.ts.map +1 -0
- package/lib/components/section.js +178 -0
- package/lib/components/section.js.map +1 -0
- package/lib/components/segments.d.ts +26 -0
- package/lib/components/segments.d.ts.map +1 -0
- package/lib/components/segments.js +105 -0
- package/lib/components/segments.js.map +1 -0
- package/lib/components/spinner.d.ts +18 -16
- package/lib/components/spinner.d.ts.map +1 -1
- package/lib/components/spinner.js +63 -47
- package/lib/components/spinner.js.map +1 -1
- package/lib/components/style.test.js +11 -11
- package/lib/components/style.test.js.map +1 -1
- package/lib/components/styled.d.ts +17 -0
- package/lib/components/styled.d.ts.map +1 -0
- package/lib/components/styled.js +107 -0
- package/lib/components/styled.js.map +1 -0
- package/lib/components/styled.test.d.ts +2 -0
- package/lib/components/styled.test.d.ts.map +1 -0
- package/lib/components/styled.test.js +135 -0
- package/lib/components/styled.test.js.map +1 -0
- package/lib/index.d.ts +17 -13
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +13 -13
- package/lib/index.js.map +1 -1
- package/lib/index.test.js +17 -11
- package/lib/index.test.js.map +1 -1
- package/lib/layout/grid.d.ts +31 -35
- package/lib/layout/grid.d.ts.map +1 -1
- package/lib/layout/grid.js +437 -216
- package/lib/layout/grid.js.map +1 -1
- package/lib/layout/grid.test.js +332 -36
- package/lib/layout/grid.test.js.map +1 -1
- package/lib/native/ansi.d.ts +9 -0
- package/lib/native/ansi.d.ts.map +1 -1
- package/lib/native/ansi.js +9 -0
- package/lib/native/ansi.js.map +1 -1
- package/lib/native/diff.d.ts +5 -1
- package/lib/native/diff.d.ts.map +1 -1
- package/lib/native/diff.js +25 -7
- package/lib/native/diff.js.map +1 -1
- package/lib/native/region-renderer-debug.test.d.ts +2 -0
- package/lib/native/region-renderer-debug.test.d.ts.map +1 -0
- package/lib/native/region-renderer-debug.test.js +45 -0
- package/lib/native/region-renderer-debug.test.js.map +1 -0
- package/lib/native/region-renderer.d.ts +57 -148
- package/lib/native/region-renderer.d.ts.map +1 -1
- package/lib/native/region-renderer.js +455 -1124
- package/lib/native/region-renderer.js.map +1 -1
- package/lib/native/region.test.js +2 -20
- package/lib/native/region.test.js.map +1 -1
- package/lib/region-resize.test.d.ts +2 -0
- package/lib/region-resize.test.d.ts.map +1 -0
- package/lib/region-resize.test.js +124 -0
- package/lib/region-resize.test.js.map +1 -0
- package/lib/region.d.ts +97 -9
- package/lib/region.d.ts.map +1 -1
- package/lib/region.js +591 -185
- package/lib/region.js.map +1 -1
- package/lib/region.test.js +3 -3
- package/lib/region.test.js.map +1 -1
- package/lib/types.d.ts +9 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/utils/file-link.d.ts +16 -0
- package/lib/utils/file-link.d.ts.map +1 -0
- package/lib/utils/file-link.js +23 -0
- package/lib/utils/file-link.js.map +1 -0
- package/lib/utils/prompt.d.ts +15 -0
- package/lib/utils/prompt.d.ts.map +1 -0
- package/lib/utils/prompt.js +128 -0
- package/lib/utils/prompt.js.map +1 -0
- package/lib/utils/terminal-theme.d.ts +36 -0
- package/lib/utils/terminal-theme.d.ts.map +1 -0
- package/lib/utils/terminal-theme.js +61 -0
- package/lib/utils/terminal-theme.js.map +1 -0
- package/lib/utils/text.d.ts +53 -3
- package/lib/utils/text.d.ts.map +1 -1
- package/lib/utils/text.js +194 -36
- package/lib/utils/text.js.map +1 -1
- package/lib/utils/wait-for-spacebar.d.ts.map +1 -1
- package/lib/utils/wait-for-spacebar.js +9 -6
- package/lib/utils/wait-for-spacebar.js.map +1 -1
- package/package.json +17 -13
|
@@ -1,1238 +1,569 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { diffFrames } from './diff';
|
|
3
4
|
import * as ansi from './ansi';
|
|
4
5
|
import { RenderBuffer } from './buffer';
|
|
5
6
|
import { Throttle } from './throttle';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { logToFile as logToFileUtil } from '../utils/debug-log';
|
|
9
|
-
/**
|
|
10
|
-
* TerminalRegion manages a rectangular region of the terminal.
|
|
11
|
-
*
|
|
12
|
-
* The region reserves new lines at the bottom of the terminal and only
|
|
13
|
-
* updates within those reserved lines. This prevents overwriting existing
|
|
14
|
-
* terminal content.
|
|
15
|
-
*
|
|
16
|
-
* Performance optimizations:
|
|
17
|
-
* - Frame diffing to minimize writes
|
|
18
|
-
* - Throttling to limit render frequency
|
|
19
|
-
* - Batched writes to stdout
|
|
20
|
-
* - Efficient string operations
|
|
21
|
-
* - Relative cursor movements (no absolute positioning)
|
|
22
|
-
*/
|
|
7
|
+
import { getTerminalHeight as getDefaultTerminalHeight } from '../utils/terminal';
|
|
8
|
+
import { truncateToWidth } from '../utils/text';
|
|
23
9
|
export class RegionRenderer {
|
|
24
10
|
width;
|
|
25
11
|
height;
|
|
26
|
-
pendingFrame
|
|
27
|
-
previousFrame
|
|
28
|
-
|
|
12
|
+
pendingFrame;
|
|
13
|
+
previousFrame;
|
|
14
|
+
disableRendering;
|
|
15
|
+
lastRenderedHeight = 0;
|
|
16
|
+
stdout;
|
|
29
17
|
throttle;
|
|
30
18
|
renderBuffer;
|
|
31
|
-
stdout;
|
|
32
|
-
disableRendering;
|
|
33
19
|
permanentlyDisabled;
|
|
34
|
-
isInitialized = false;
|
|
35
|
-
initializationPromise = null; // Promise for async initialization
|
|
36
|
-
resizeCleanup;
|
|
37
|
-
widthExplicitlySet;
|
|
38
|
-
savedCursorPosition = false; // Track if we've saved the cursor position
|
|
39
|
-
autoWrapDisabled = false; // Track if we've disabled terminal auto-wrap
|
|
40
|
-
startRow = null; // Absolute terminal row where region starts (1-based)
|
|
41
|
-
visibleRegionTopRow = null; // Terminal row of the top of the visible region (1-based, updated after scrolls)
|
|
42
|
-
lastRenderedHeight = 0; // Track height from last render to detect changes
|
|
43
|
-
hasRendered = false; // Track if we've rendered at least once
|
|
44
|
-
isRendering = false; // Prevent concurrent renders
|
|
45
|
-
resizeJustHappened = false; // Track if resize happened to re-initialize cursor
|
|
46
|
-
queriedCursorPosition = null; // Queried position after resize
|
|
47
|
-
static logFileCleared = false; // Track if log file has been cleared for this process
|
|
48
20
|
onKeepAlive;
|
|
49
|
-
|
|
21
|
+
viewportWidth;
|
|
22
|
+
viewportHeight;
|
|
23
|
+
previousViewportFrame;
|
|
24
|
+
effectiveWidth;
|
|
25
|
+
autoWrapDisabled = false;
|
|
26
|
+
inAlternateScreen = false;
|
|
27
|
+
isRendering = false;
|
|
28
|
+
renderTimer = null;
|
|
29
|
+
resizeCleanup;
|
|
30
|
+
destroyed = false;
|
|
31
|
+
debugLogPath;
|
|
32
|
+
debugLogCleared = false;
|
|
33
|
+
cursorVisible = false;
|
|
50
34
|
static exitHandlerSetup = false;
|
|
51
35
|
static activeRegions = new Set();
|
|
52
36
|
constructor(options = {}) {
|
|
53
|
-
this.widthExplicitlySet = options.width !== undefined;
|
|
54
|
-
// CRITICAL: getTerminalWidth() returns (terminal_width - 2) to prevent cursor wrapping
|
|
55
|
-
// Components receive this as availableWidth and can write up to the full availableWidth
|
|
56
|
-
const maxSafeWidth = getTerminalWidth();
|
|
57
|
-
this.width = options.width !== undefined
|
|
58
|
-
? Math.min(options.width, maxSafeWidth)
|
|
59
|
-
: maxSafeWidth;
|
|
60
|
-
this.height = options.height ?? 1;
|
|
61
|
-
this.lastRenderedHeight = 0; // Start at 0 - will be set after first render
|
|
62
|
-
this.hasRendered = false; // Haven't rendered yet
|
|
63
37
|
this.stdout = options.stdout ?? process.stdout;
|
|
64
|
-
this.permanentlyDisabled = options.disableRendering ?? false;
|
|
65
|
-
this.disableRendering = this.permanentlyDisabled;
|
|
66
38
|
this.onKeepAlive = options.onKeepAlive;
|
|
67
|
-
|
|
39
|
+
this.viewportWidth = this.readViewportWidth();
|
|
40
|
+
this.viewportHeight = this.readViewportHeight();
|
|
41
|
+
this.width = this.viewportWidth;
|
|
42
|
+
this.height = 1;
|
|
68
43
|
this.pendingFrame = Array(this.height).fill('');
|
|
69
44
|
this.previousFrame = Array(this.height).fill('');
|
|
70
|
-
this.
|
|
45
|
+
this.previousViewportFrame = Array(Math.max(1, this.viewportHeight)).fill('');
|
|
46
|
+
this.throttle = new Throttle(30);
|
|
71
47
|
this.renderBuffer = new RenderBuffer(this.stdout);
|
|
72
|
-
|
|
73
|
-
|
|
48
|
+
this.permanentlyDisabled = options.disableRendering ?? false;
|
|
49
|
+
this.disableRendering = this.permanentlyDisabled;
|
|
50
|
+
this.debugLogPath = this.resolveDebugLogPath(options.debugLog);
|
|
51
|
+
this.effectiveWidth = this.viewportWidth;
|
|
74
52
|
if (!this.permanentlyDisabled) {
|
|
75
|
-
this.
|
|
76
|
-
}
|
|
77
|
-
// Set up resize handling if width was not explicitly set (auto-resize enabled)
|
|
78
|
-
if (!this.widthExplicitlySet && !this.permanentlyDisabled) {
|
|
53
|
+
this.initializeTerminalState();
|
|
79
54
|
this.setupResizeHandler();
|
|
80
|
-
|
|
81
|
-
// Set up automatic cleanup on process exit
|
|
82
|
-
// This ensures regions are properly cleaned up even if destroy() isn't called
|
|
83
|
-
// Use singleton pattern to prevent memory leak from multiple listeners
|
|
84
|
-
if (!this.permanentlyDisabled) {
|
|
85
|
-
RegionRenderer.setupExitHandler();
|
|
86
|
-
RegionRenderer.activeRegions.add(this);
|
|
55
|
+
RegionRenderer.registerRegion(this);
|
|
87
56
|
}
|
|
88
57
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
58
|
+
getWidth() {
|
|
59
|
+
this.width = this.viewportWidth;
|
|
60
|
+
this.effectiveWidth = this.viewportWidth;
|
|
61
|
+
return this.width;
|
|
62
|
+
}
|
|
63
|
+
getHeight() {
|
|
64
|
+
return this.height;
|
|
65
|
+
}
|
|
66
|
+
async getStartRow() {
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
setThrottleFps(fps) {
|
|
70
|
+
this.throttle.setFps(fps);
|
|
71
|
+
}
|
|
72
|
+
setLine(lineNumber, content) {
|
|
73
|
+
if (lineNumber < 1) {
|
|
74
|
+
throw new Error('Line numbers start at 1');
|
|
97
75
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
76
|
+
const index = lineNumber - 1;
|
|
77
|
+
this.ensureFrameSize(index + 1);
|
|
78
|
+
// Log if we're overwriting non-empty content (potential duplication)
|
|
79
|
+
if (this.pendingFrame[index] && this.pendingFrame[index].trim().length > 0 && content.trim().length > 0) {
|
|
80
|
+
const oldContent = this.pendingFrame[index].substring(0, 40);
|
|
81
|
+
const newContent = content.substring(0, 40);
|
|
82
|
+
if (oldContent !== newContent) {
|
|
83
|
+
this.logToFile(`[setLine] WARNING: Overwriting line ${lineNumber} - old: "${oldContent}", new: "${newContent}"`);
|
|
106
84
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// Register cleanup on various exit events (only once globally)
|
|
110
|
-
process.once('exit', cleanup);
|
|
111
|
-
process.once('SIGINT', cleanup);
|
|
112
|
-
process.once('SIGTERM', cleanup);
|
|
113
|
-
// Also handle uncaught exceptions (but don't prevent default behavior)
|
|
114
|
-
const originalUncaughtException = process.listeners('uncaughtException');
|
|
115
|
-
process.once('uncaughtException', (error, origin) => {
|
|
116
|
-
cleanup();
|
|
117
|
-
// Re-emit if there were other listeners
|
|
118
|
-
if (originalUncaughtException.length > 0) {
|
|
119
|
-
originalUncaughtException.forEach((listener) => {
|
|
120
|
-
listener(error, origin);
|
|
121
|
-
});
|
|
85
|
+
else {
|
|
86
|
+
this.logToFile(`[setLine] Writing same content to line ${lineNumber}: "${newContent}"`);
|
|
122
87
|
}
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Set up resize event handler to react to terminal size changes
|
|
127
|
-
*/
|
|
128
|
-
setupResizeHandler() {
|
|
129
|
-
// If a custom stdout is provided (e.g., for testing),
|
|
130
|
-
// listen to its resize events. Otherwise, use the global onResize utility.
|
|
131
|
-
if (this.stdout && this.stdout !== process.stdout) {
|
|
132
|
-
// Custom stdout - listen to its resize events directly
|
|
133
|
-
const resizeHandler = () => {
|
|
134
|
-
// CRITICAL: Don't modify terminal state if region is destroyed
|
|
135
|
-
// This prevents leaving terminal in bad state after program finishes
|
|
136
|
-
if (!this.isInitialized) {
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
// CRITICAL: Always re-disable auto-wrap on resize if region is initialized
|
|
140
|
-
// Some terminals reset auto-wrap state on resize, so we must re-disable it
|
|
141
|
-
// This ensures content doesn't reflow automatically when terminal resizes
|
|
142
|
-
// Write directly to stdout since this happens outside of render cycle
|
|
143
|
-
if (!this.disableRendering && this.isInitialized) {
|
|
144
|
-
this.stdout.write(ansi.DISABLE_AUTO_WRAP);
|
|
145
|
-
this.autoWrapDisabled = true;
|
|
146
|
-
}
|
|
147
|
-
// Only auto-resize if width wasn't explicitly set by the user
|
|
148
|
-
if (!this.widthExplicitlySet) {
|
|
149
|
-
const oldWidth = this.width;
|
|
150
|
-
// Read width directly from the custom stdout
|
|
151
|
-
// CRITICAL: getTerminalWidth() returns (terminal_width - 2) to prevent cursor wrapping
|
|
152
|
-
const actualWidth = getTerminalWidth();
|
|
153
|
-
// Only update if it actually changed
|
|
154
|
-
if (actualWidth !== oldWidth) {
|
|
155
|
-
this.width = actualWidth;
|
|
156
|
-
// Don't auto-render here - the high-level API (or user code) will handle rebuilding
|
|
157
|
-
// grid layouts and other dynamic content with the new width
|
|
158
|
-
// This prevents rendering broken layouts before they're rebuilt
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
// CRITICAL: Mark that resize happened - cursor position may be invalid
|
|
162
|
-
// We'll re-initialize cursor position on next render
|
|
163
|
-
this.resizeJustHappened = true;
|
|
164
|
-
this.savedCursorPosition = false;
|
|
165
|
-
// CRITICAL: Trigger re-render via high-level API to update content with new width
|
|
166
|
-
// This prevents content from being rendered over (like spacebar prompt)
|
|
167
|
-
// The old code was a single class, so it didn't need this callback
|
|
168
|
-
// But our split architecture needs to notify the high-level API to re-render
|
|
169
|
-
if (this.onKeepAlive) {
|
|
170
|
-
this.onKeepAlive();
|
|
171
|
-
}
|
|
172
|
-
};
|
|
173
|
-
this.stdout.on('resize', resizeHandler);
|
|
174
|
-
this.resizeCleanup = () => {
|
|
175
|
-
this.stdout.off('resize', resizeHandler);
|
|
176
|
-
};
|
|
177
88
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.
|
|
181
|
-
// CRITICAL: Don't modify terminal state if region is destroyed
|
|
182
|
-
// This prevents leaving terminal in bad state after program finishes
|
|
183
|
-
if (!this.isInitialized) {
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
// CRITICAL: Always re-disable auto-wrap on resize if region is initialized
|
|
187
|
-
// Some terminals reset auto-wrap state on resize, so we must re-disable it
|
|
188
|
-
// This ensures content doesn't reflow automatically when terminal resizes
|
|
189
|
-
// Write directly to stdout since this happens outside of render cycle
|
|
190
|
-
if (!this.disableRendering && this.isInitialized) {
|
|
191
|
-
this.stdout.write(ansi.DISABLE_AUTO_WRAP);
|
|
192
|
-
this.autoWrapDisabled = true;
|
|
193
|
-
}
|
|
194
|
-
// Only auto-resize if width wasn't explicitly set by the user
|
|
195
|
-
if (!this.widthExplicitlySet) {
|
|
196
|
-
const oldWidth = this.width;
|
|
197
|
-
// Read width directly from stdout to ensure we get the latest value
|
|
198
|
-
// Node.js updates process.stdout.columns when resize happens
|
|
199
|
-
// CRITICAL: getTerminalWidth() returns (terminal_width - 2) to prevent cursor wrapping
|
|
200
|
-
const actualWidth = getTerminalWidth();
|
|
201
|
-
// Only update if it actually changed
|
|
202
|
-
if (actualWidth !== oldWidth) {
|
|
203
|
-
this.width = actualWidth;
|
|
204
|
-
// Don't auto-render here - the high-level API (or user code) will handle rebuilding
|
|
205
|
-
// flex layouts and other dynamic content with the new width
|
|
206
|
-
// This prevents rendering broken layouts before they're rebuilt
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
// CRITICAL: Mark that resize happened - cursor position may be invalid
|
|
210
|
-
// We'll query the actual cursor position and recalibrate
|
|
211
|
-
// NOTE: Don't clear savedCursorPosition - we'll handle it in renderNow()
|
|
212
|
-
// by not restoring it when resizeJustHappened=true
|
|
213
|
-
this.logToFile(`[resize handler] SETTING resizeJustHappened=true`);
|
|
214
|
-
this.resizeJustHappened = true;
|
|
215
|
-
// DON'T clear savedCursorPosition - it might still be valid, and we'll handle
|
|
216
|
-
// the resize case in renderNow() by not restoring it
|
|
217
|
-
// CRITICAL: Query cursor position after resize to use actual position instead of saved
|
|
218
|
-
// The saved position becomes invalid when terminal scrolls
|
|
219
|
-
// NOTE: Disabled for now - the polling interferes with stdin (causes hang)
|
|
220
|
-
// this.queryCursorPositionAfterResize();
|
|
221
|
-
// Also poll multiple times for debugging
|
|
222
|
-
// NOTE: Disabled for now - the polling interferes with stdin (causes hang)
|
|
223
|
-
// this.pollCursorPositionAfterResize();
|
|
224
|
-
// CRITICAL: Trigger re-render via high-level API to update content with new width
|
|
225
|
-
// This prevents content from being rendered over (like spacebar prompt)
|
|
226
|
-
// The old code was a single class, so it didn't need this callback
|
|
227
|
-
// But our split architecture needs to notify the high-level API to re-render
|
|
228
|
-
// NOTE: Disabled cursor position querying for now - it was causing issues
|
|
229
|
-
// The queried position might be at the bottom of the screen, not where the region actually is
|
|
230
|
-
// if (this.onKeepAlive) {
|
|
231
|
-
// this.onKeepAlive();
|
|
232
|
-
// }
|
|
233
|
-
// TODO: Re-enable cursor querying once we figure out how to use it correctly
|
|
234
|
-
if (this.onKeepAlive) {
|
|
235
|
-
this.onKeepAlive();
|
|
236
|
-
}
|
|
237
|
-
});
|
|
89
|
+
this.pendingFrame[index] = content;
|
|
90
|
+
if (lineNumber > this.height) {
|
|
91
|
+
this.height = lineNumber;
|
|
238
92
|
}
|
|
93
|
+
this.scheduleRender();
|
|
239
94
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
* This appends new lines so the region doesn't overwrite existing content.
|
|
243
|
-
*
|
|
244
|
-
* Also disables terminal auto-wrap so we can manage all wrapping ourselves.
|
|
245
|
-
*/
|
|
246
|
-
async initializeRegion() {
|
|
247
|
-
// Disable terminal auto-wrap - we'll manage all wrapping ourselves
|
|
248
|
-
// This makes reflow math much easier because we control it, not the terminal
|
|
249
|
-
// IMPORTANT: Write directly to stdout and flush immediately so it takes effect before any content
|
|
250
|
-
if (!this.disableRendering && !this.autoWrapDisabled) {
|
|
251
|
-
this.stdout.write(ansi.DISABLE_AUTO_WRAP);
|
|
252
|
-
this.autoWrapDisabled = true;
|
|
253
|
-
}
|
|
254
|
-
if (this.isInitialized)
|
|
95
|
+
updateLines(updates) {
|
|
96
|
+
if (updates.length === 0) {
|
|
255
97
|
return;
|
|
256
|
-
// CRITICAL: Query cursor position BEFORE printing newlines
|
|
257
|
-
// This establishes where the region starts in terminal space
|
|
258
|
-
// We query BEFORE rendering so stdin is restored before waitForSpacebar
|
|
259
|
-
let queriedRow = null;
|
|
260
|
-
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
261
|
-
const queryStartTime = Date.now();
|
|
262
|
-
try {
|
|
263
|
-
this.logToFile(`[initializeRegion] QUERYING cursor position before printing newlines (timestamp: ${new Date().toISOString()})...`);
|
|
264
|
-
const pos = await queryCursorPosition(500);
|
|
265
|
-
const queryEndTime = Date.now();
|
|
266
|
-
const queryDuration = queryEndTime - queryStartTime;
|
|
267
|
-
queriedRow = pos.row;
|
|
268
|
-
// CRITICAL: Start two lines BELOW the cursor to avoid overwriting the prompt and PNPM output
|
|
269
|
-
// The cursor is at the prompt line, and there may be PNPM output on the line above or at the cursor
|
|
270
|
-
// So we start the region two lines below to be safe
|
|
271
|
-
this.startRow = pos.row + 2;
|
|
272
|
-
this.logToFile(`[initializeRegion] ✓ GOT cursor position back: row=${pos.row}, col=${pos.col}, setting startRow=${this.startRow} (two lines below cursor to avoid overwriting prompt and PNPM output, query took ${queryDuration}ms, timestamp: ${new Date().toISOString()})`);
|
|
273
|
-
// CRITICAL: At this point, queryCursorPosition has cleaned up and restored stdin
|
|
274
|
-
// stdin should be in a clean state for waitForSpacebar
|
|
275
|
-
}
|
|
276
|
-
catch (err) {
|
|
277
|
-
const queryEndTime = Date.now();
|
|
278
|
-
const queryDuration = queryEndTime - queryStartTime;
|
|
279
|
-
// Query failed - estimate from terminal height
|
|
280
|
-
const terminalHeight = process.stdout.isTTY && process.stdout.rows ? process.stdout.rows : 24;
|
|
281
|
-
this.startRow = terminalHeight - this.height + 1;
|
|
282
|
-
this.logToFile(`[initializeRegion] ✗ Cursor query FAILED after ${queryDuration}ms: ${err instanceof Error ? err.message : String(err)}, estimating startRow=${this.startRow} (timestamp: ${new Date().toISOString()})`);
|
|
283
|
-
}
|
|
284
98
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
// After query cleanup, the cursor should still be at approximately the queried position
|
|
295
|
-
// So we need to move down (startRow - queriedRow) = 2 lines to get to startRow
|
|
296
|
-
if (queriedRow !== null) {
|
|
297
|
-
const linesToMoveDown = this.startRow - queriedRow;
|
|
298
|
-
if (linesToMoveDown > 0) {
|
|
299
|
-
this.stdout.write(ansi.moveCursorDown(linesToMoveDown));
|
|
300
|
-
this.logToFile(`[initializeRegion] Moved down ${linesToMoveDown} lines to position at startRow=${this.startRow} (queriedRow=${queriedRow})`);
|
|
99
|
+
for (const { lineNumber, content } of updates) {
|
|
100
|
+
if (lineNumber < 1) {
|
|
101
|
+
throw new Error('Line numbers start at 1');
|
|
102
|
+
}
|
|
103
|
+
const index = lineNumber - 1;
|
|
104
|
+
this.ensureFrameSize(index + 1);
|
|
105
|
+
this.pendingFrame[index] = content;
|
|
106
|
+
if (lineNumber > this.height) {
|
|
107
|
+
this.height = lineNumber;
|
|
301
108
|
}
|
|
302
109
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
// But we want to save at the START of the LAST line (to match where we save after rendering)
|
|
312
|
-
// So move up one line to get to the start of the last line
|
|
313
|
-
if (this.height > 0) {
|
|
314
|
-
this.stdout.write(ansi.moveCursorUp(1));
|
|
315
|
-
}
|
|
316
|
-
this.stdout.write(ansi.MOVE_TO_START_OF_LINE);
|
|
317
|
-
// Now we're at the start of the last line - save this position
|
|
318
|
-
// Note: After rendering (when there's content), we save at the END of content
|
|
319
|
-
// But for initialization, we're creating empty lines, so start of line is appropriate
|
|
320
|
-
this.stdout.write(ansi.SAVE_CURSOR);
|
|
321
|
-
this.savedCursorPosition = true;
|
|
110
|
+
this.scheduleRender();
|
|
111
|
+
}
|
|
112
|
+
set(content) {
|
|
113
|
+
const lines = content.split('\n');
|
|
114
|
+
this.pendingFrame = [...lines];
|
|
115
|
+
this.previousFrame = new Array(lines.length).fill('');
|
|
116
|
+
this.height = lines.length;
|
|
117
|
+
this.scheduleRender();
|
|
322
118
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
*/
|
|
327
|
-
async queryCursorPositionAfterResize() {
|
|
328
|
-
if (this.disableRendering || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
329
|
-
return; // Can't query if not a TTY
|
|
119
|
+
getLine(lineNumber) {
|
|
120
|
+
if (lineNumber < 1) {
|
|
121
|
+
throw new Error('Line numbers start at 1');
|
|
330
122
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
this.logToFile(`[cursor query] After resize - queried cursor at row=${pos.row}, col=${pos.col}`);
|
|
123
|
+
return this.pendingFrame[lineNumber - 1] ?? '';
|
|
124
|
+
}
|
|
125
|
+
clearLine(lineNumber) {
|
|
126
|
+
if (lineNumber < 1) {
|
|
127
|
+
throw new Error('Line numbers start at 1');
|
|
337
128
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
129
|
+
const index = lineNumber - 1;
|
|
130
|
+
if (index >= this.pendingFrame.length) {
|
|
131
|
+
return;
|
|
341
132
|
}
|
|
133
|
+
this.pendingFrame[index] = '';
|
|
134
|
+
this.scheduleRender();
|
|
342
135
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
*/
|
|
347
|
-
pollCursorPositionAfterResize() {
|
|
348
|
-
if (this.disableRendering || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
349
|
-
return; // Can't poll if not a TTY
|
|
136
|
+
clear() {
|
|
137
|
+
for (let i = 0; i < this.pendingFrame.length; i++) {
|
|
138
|
+
this.pendingFrame[i] = '';
|
|
350
139
|
}
|
|
351
|
-
|
|
352
|
-
const maxPolls = 10; // Poll 10 times over ~1 second
|
|
353
|
-
const pollInterval = 100; // 100ms between polls
|
|
354
|
-
const poll = async () => {
|
|
355
|
-
try {
|
|
356
|
-
const pos = await queryCursorPosition(500); // 500ms timeout per poll
|
|
357
|
-
pollCount++;
|
|
358
|
-
this.logToFile(`[cursor poll ${pollCount}/${maxPolls}] After resize - cursor at row=${pos.row}, col=${pos.col}`);
|
|
359
|
-
// After first few polls, trigger a redraw, then continue polling
|
|
360
|
-
// This helps us see if the cursor position changes after redraw
|
|
361
|
-
if (pollCount === 3 && this.onKeepAlive) {
|
|
362
|
-
this.logToFile(`[cursor poll] Triggering redraw after 3 polls (cursor was at row=${pos.row}, col=${pos.col})`);
|
|
363
|
-
this.onKeepAlive();
|
|
364
|
-
// Wait a bit for redraw to complete, then continue polling
|
|
365
|
-
setTimeout(() => {
|
|
366
|
-
if (pollCount < maxPolls) {
|
|
367
|
-
setTimeout(poll, pollInterval);
|
|
368
|
-
}
|
|
369
|
-
}, 100); // Give redraw more time to complete
|
|
370
|
-
}
|
|
371
|
-
else if (pollCount < maxPolls) {
|
|
372
|
-
setTimeout(poll, pollInterval);
|
|
373
|
-
}
|
|
374
|
-
else {
|
|
375
|
-
this.logToFile(`[cursor poll] Finished polling after ${maxPolls} attempts`);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
catch (err) {
|
|
379
|
-
pollCount++;
|
|
380
|
-
this.logToFile(`[cursor poll ${pollCount}/${maxPolls}] Failed to query cursor position: ${err instanceof Error ? err.message : String(err)}`);
|
|
381
|
-
if (pollCount < maxPolls) {
|
|
382
|
-
setTimeout(poll, pollInterval);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
};
|
|
386
|
-
// Start polling after a small delay to let resize settle
|
|
387
|
-
setTimeout(() => {
|
|
388
|
-
this.logToFile(`[cursor poll] Starting cursor position polling after resize`);
|
|
389
|
-
poll();
|
|
390
|
-
}, 10);
|
|
140
|
+
this.scheduleRender();
|
|
391
141
|
}
|
|
392
|
-
|
|
393
|
-
|
|
142
|
+
async flush() {
|
|
143
|
+
await this.renderNow();
|
|
394
144
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
145
|
+
expandTo(newHeight) {
|
|
146
|
+
if (newHeight <= this.height) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.ensureFrameSize(newHeight);
|
|
150
|
+
this.height = newHeight;
|
|
400
151
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
*/
|
|
404
|
-
getTerminalHeight() {
|
|
405
|
-
return this.stdout.isTTY && this.stdout.rows
|
|
406
|
-
? this.stdout.rows
|
|
407
|
-
: 24; // fallback
|
|
152
|
+
setHeight(height) {
|
|
153
|
+
this.height = height;
|
|
408
154
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if (
|
|
420
|
-
return
|
|
155
|
+
shrinkFrame(startIndex, count) {
|
|
156
|
+
this.pendingFrame.splice(startIndex, count);
|
|
157
|
+
if (this.previousFrame.length >= startIndex + count) {
|
|
158
|
+
this.previousFrame.splice(startIndex, count);
|
|
159
|
+
}
|
|
160
|
+
// Clear previousViewportFrame so next render recalculates from scratch
|
|
161
|
+
// This ensures deleted lines are properly handled
|
|
162
|
+
this.previousViewportFrame = [];
|
|
163
|
+
}
|
|
164
|
+
async destroy(clearFirst = false) {
|
|
165
|
+
if (this.destroyed) {
|
|
166
|
+
return;
|
|
421
167
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
// Skip ANSI code
|
|
428
|
-
let ansiEnd = charPos + 1;
|
|
429
|
-
while (ansiEnd < content.length) {
|
|
430
|
-
if (content[ansiEnd] === 'm') {
|
|
431
|
-
ansiEnd++;
|
|
432
|
-
break;
|
|
433
|
-
}
|
|
434
|
-
if ((content[ansiEnd] >= '0' && content[ansiEnd] <= '9') ||
|
|
435
|
-
content[ansiEnd] === ';' ||
|
|
436
|
-
content[ansiEnd] === '[') {
|
|
437
|
-
ansiEnd++;
|
|
438
|
-
}
|
|
439
|
-
else {
|
|
440
|
-
break;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
charPos = ansiEnd;
|
|
444
|
-
}
|
|
445
|
-
else {
|
|
446
|
-
charPos++;
|
|
447
|
-
visualPos++;
|
|
448
|
-
}
|
|
168
|
+
this.destroyed = true;
|
|
169
|
+
RegionRenderer.activeRegions.delete(this);
|
|
170
|
+
if (this.renderTimer) {
|
|
171
|
+
clearTimeout(this.renderTimer);
|
|
172
|
+
this.renderTimer = null;
|
|
449
173
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
* Clear the current line and move to start
|
|
454
|
-
*/
|
|
455
|
-
clearCurrentLine() {
|
|
456
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
457
|
-
this.renderBuffer.write(ansi.CLEAR_LINE);
|
|
458
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
459
|
-
}
|
|
460
|
-
/**
|
|
461
|
-
* Move to top-left of region and save cursor position
|
|
462
|
-
*/
|
|
463
|
-
moveToTopLeftAndSave(linesToRender, context = '') {
|
|
464
|
-
// After rendering, we're at the end of the last rendered line
|
|
465
|
-
// To get to the top-left (start of first line), we need to move up (linesToRender - 1) lines
|
|
466
|
-
// Then move to start of line
|
|
467
|
-
// CRITICAL: With auto-wrap disabled, the cursor stays on the same line after writing
|
|
468
|
-
// So if we rendered 1 line, we're on line 1 (at the end), and we move up 0 lines
|
|
469
|
-
// If we rendered N lines, we're on line N (at the end), and we move up (N - 1) lines
|
|
470
|
-
const linesToMoveUp = linesToRender > 1 ? linesToRender - 1 : 0;
|
|
471
|
-
this.logToFile(`[moveToTopLeftAndSave] linesToRender=${linesToRender}, moving up ${linesToMoveUp} lines to get to top-left (after rendering, cursor is at end of last line)`);
|
|
472
|
-
if (linesToMoveUp > 0) {
|
|
473
|
-
this.renderBuffer.write(ansi.moveCursorUp(linesToMoveUp));
|
|
174
|
+
if (this.resizeCleanup) {
|
|
175
|
+
this.resizeCleanup();
|
|
176
|
+
this.resizeCleanup = undefined;
|
|
474
177
|
}
|
|
475
|
-
this.
|
|
476
|
-
|
|
477
|
-
this.savedCursorPosition = true;
|
|
478
|
-
if (context) {
|
|
479
|
-
this.logToFile(`[renderNow] Saved cursor at top-left ${context}`);
|
|
178
|
+
if (this.permanentlyDisabled) {
|
|
179
|
+
return;
|
|
480
180
|
}
|
|
481
|
-
|
|
482
|
-
this.
|
|
181
|
+
if (!clearFirst) {
|
|
182
|
+
await this.renderNow();
|
|
483
183
|
}
|
|
184
|
+
this.leaveAlternateScreen(clearFirst);
|
|
185
|
+
this.pendingFrame = [];
|
|
186
|
+
this.previousFrame = [];
|
|
187
|
+
this.previousViewportFrame = [];
|
|
484
188
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
hasContentChanged() {
|
|
489
|
-
const contentChanged = this.previousFrame.length !== this.pendingFrame.length ||
|
|
490
|
-
this.previousFrame.some((line, i) => {
|
|
491
|
-
const prevLine = this.stripAnsi(line);
|
|
492
|
-
const pendingLine = this.stripAnsi(this.pendingFrame[i] ?? '');
|
|
493
|
-
if (prevLine !== pendingLine) {
|
|
494
|
-
// Log the first difference found for debugging
|
|
495
|
-
if (i < 5) { // Only log first few differences to avoid spam
|
|
496
|
-
this.logToFile(`[hasContentChanged] Difference at index ${i}: prev="${prevLine.substring(0, 40)}" vs pending="${pendingLine.substring(0, 40)}"`);
|
|
497
|
-
}
|
|
498
|
-
return true;
|
|
499
|
-
}
|
|
500
|
-
return false;
|
|
501
|
-
});
|
|
502
|
-
const heightChanged = this.height !== this.lastRenderedHeight;
|
|
503
|
-
return { contentChanged, heightChanged };
|
|
504
|
-
}
|
|
505
|
-
/**
|
|
506
|
-
* Expand region to accommodate more lines
|
|
507
|
-
*/
|
|
508
|
-
expandTo(newHeight) {
|
|
509
|
-
this.logToFile(`[expandTo] CALLED: oldHeight=${this.height} newHeight=${newHeight} lastRenderedHeight=${this.lastRenderedHeight} isInitialized=${this.isInitialized} disableRendering=${this.disableRendering}`);
|
|
510
|
-
const oldHeight = this.height;
|
|
511
|
-
this.height = newHeight;
|
|
512
|
-
// Expand pending frame
|
|
513
|
-
while (this.pendingFrame.length < newHeight) {
|
|
514
|
-
this.pendingFrame.push('');
|
|
189
|
+
logToFile(message) {
|
|
190
|
+
if (!this.debugLogPath) {
|
|
191
|
+
return;
|
|
515
192
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (!this.disableRendering) {
|
|
521
|
-
while (this.previousFrame.length < newHeight) {
|
|
522
|
-
this.previousFrame.push('');
|
|
193
|
+
try {
|
|
194
|
+
if (!this.debugLogCleared) {
|
|
195
|
+
fs.writeFileSync(this.debugLogPath, '# Linecraft Debug Log\n# Debug output from RegionRenderer\n\n', 'utf8');
|
|
196
|
+
this.debugLogCleared = true;
|
|
523
197
|
}
|
|
198
|
+
const timestamp = new Date().toISOString();
|
|
199
|
+
fs.appendFileSync(this.debugLogPath, `[${timestamp}] ${message}\n`, 'utf8');
|
|
524
200
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
// This prevents writing newlines multiple times on resize when height hasn't changed
|
|
528
|
-
if (this.isInitialized && newHeight > oldHeight) {
|
|
529
|
-
const terminalHeight = this.getTerminalHeight();
|
|
530
|
-
const additionalLines = newHeight - oldHeight;
|
|
531
|
-
// CRITICAL: If rendering was permanently disabled (tests), never write to stdout
|
|
532
|
-
if (this.permanentlyDisabled) {
|
|
533
|
-
this.logToFile(`[expandTo] Rendering permanently disabled - skipping newline writes but tracking height (${this.lastRenderedHeight} -> ${newHeight})`);
|
|
534
|
-
this.lastRenderedHeight = newHeight;
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
// Even if rendering is temporarily disabled (batching), we still need to reserve space
|
|
538
|
-
// Only print newlines if the region still fits in viewport
|
|
539
|
-
const shouldReserveLines = oldHeight < terminalHeight && this.lastRenderedHeight < newHeight;
|
|
540
|
-
this.logToFile(`[expandTo] oldHeight=${oldHeight} newHeight=${newHeight} lastRenderedHeight=${this.lastRenderedHeight} terminalHeight=${terminalHeight} shouldReserveLines=${shouldReserveLines} disableRendering=${this.disableRendering}`);
|
|
541
|
-
if (shouldReserveLines) {
|
|
542
|
-
this.logToFile(`[expandTo] WRITING ${additionalLines} newlines (reserving space even though disableRendering may be ${this.disableRendering})`);
|
|
543
|
-
for (let i = 0; i < additionalLines; i++) {
|
|
544
|
-
this.stdout.write('\n');
|
|
545
|
-
}
|
|
546
|
-
if (newHeight > 0) {
|
|
547
|
-
this.stdout.write(ansi.moveCursorUp(1));
|
|
548
|
-
}
|
|
549
|
-
this.stdout.write(ansi.MOVE_TO_START_OF_LINE);
|
|
550
|
-
this.stdout.write(ansi.SAVE_CURSOR);
|
|
551
|
-
this.savedCursorPosition = true;
|
|
552
|
-
this.logToFile(`[expandTo] UPDATING lastRenderedHeight from ${this.lastRenderedHeight} to ${newHeight} (after writing newlines)`);
|
|
553
|
-
this.lastRenderedHeight = newHeight;
|
|
554
|
-
}
|
|
555
|
-
else {
|
|
556
|
-
this.logToFile(`[expandTo] Region already exceeds viewport or already reserved at this height - skipping newline writes`);
|
|
557
|
-
}
|
|
201
|
+
catch {
|
|
202
|
+
// ignore logging failures
|
|
558
203
|
}
|
|
559
204
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
*/
|
|
564
|
-
getLine(lineNumber) {
|
|
565
|
-
if (lineNumber < 1) {
|
|
566
|
-
throw new Error('Line numbers start at 1');
|
|
567
|
-
}
|
|
568
|
-
const lineIndex = lineNumber - 1;
|
|
569
|
-
if (lineIndex >= this.pendingFrame.length) {
|
|
570
|
-
return '';
|
|
571
|
-
}
|
|
572
|
-
return this.pendingFrame[lineIndex] || '';
|
|
205
|
+
static registerRegion(region) {
|
|
206
|
+
RegionRenderer.activeRegions.add(region);
|
|
207
|
+
RegionRenderer.setupExitHandler();
|
|
573
208
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
* Note: With auto-wrap disabled, we manage all wrapping ourselves.
|
|
578
|
-
* This method sets a single line - if content needs to wrap, it should
|
|
579
|
-
* be handled by the component layer (col, flex, etc.) before calling this.
|
|
580
|
-
*/
|
|
581
|
-
setLine(lineNumber, content) {
|
|
582
|
-
if (lineNumber < 1) {
|
|
583
|
-
throw new Error('Line numbers start at 1');
|
|
584
|
-
}
|
|
585
|
-
const lineIndex = lineNumber - 1;
|
|
586
|
-
// CRITICAL: Don't expand beyond current height if height was explicitly set
|
|
587
|
-
// The region.set() method for components sets the height explicitly,
|
|
588
|
-
// so we should not expand here. Only expand if we're in "auto-expand" mode.
|
|
589
|
-
// For now, we'll allow expansion but the caller (region.set) will truncate after rendering.
|
|
590
|
-
if (lineIndex >= this.height) {
|
|
591
|
-
this.expandTo(lineIndex + 1);
|
|
592
|
-
// Update height to match
|
|
593
|
-
this.height = lineIndex + 1;
|
|
594
|
-
}
|
|
595
|
-
// Ensure pending frame has enough lines
|
|
596
|
-
while (this.pendingFrame.length <= lineIndex) {
|
|
597
|
-
this.pendingFrame.push('');
|
|
209
|
+
static setupExitHandler() {
|
|
210
|
+
if (RegionRenderer.exitHandlerSetup) {
|
|
211
|
+
return;
|
|
598
212
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
213
|
+
RegionRenderer.exitHandlerSetup = true;
|
|
214
|
+
const cleanup = () => {
|
|
215
|
+
for (const region of RegionRenderer.activeRegions) {
|
|
216
|
+
region.destroy(true).catch(() => { });
|
|
217
|
+
}
|
|
218
|
+
RegionRenderer.activeRegions.clear();
|
|
219
|
+
};
|
|
220
|
+
process.once('exit', cleanup);
|
|
221
|
+
process.once('SIGINT', cleanup);
|
|
222
|
+
process.once('SIGTERM', cleanup);
|
|
603
223
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
*/
|
|
607
|
-
set(content) {
|
|
608
|
-
// Split by newlines
|
|
609
|
-
const lines = content.split('\n');
|
|
610
|
-
// Expand region if needed
|
|
611
|
-
if (lines.length > this.height) {
|
|
612
|
-
this.expandTo(lines.length);
|
|
613
|
-
}
|
|
614
|
-
// Update all lines in pending frame
|
|
615
|
-
// If new content has fewer lines than current height, clear the extra lines
|
|
616
|
-
this.pendingFrame = [...lines];
|
|
617
|
-
// Ensure frame is the right size - pad with empty strings if needed
|
|
618
|
-
while (this.pendingFrame.length < this.height) {
|
|
224
|
+
ensureFrameSize(size) {
|
|
225
|
+
while (this.pendingFrame.length < size) {
|
|
619
226
|
this.pendingFrame.push('');
|
|
620
227
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
if (lines.length < this.previousFrame.length) {
|
|
624
|
-
for (let i = lines.length; i < this.previousFrame.length; i++) {
|
|
625
|
-
this.previousFrame[i] = this.previousFrame[i] || '';
|
|
626
|
-
}
|
|
228
|
+
while (this.previousFrame.length < size) {
|
|
229
|
+
this.previousFrame.push('');
|
|
627
230
|
}
|
|
628
|
-
// Schedule render
|
|
629
|
-
this.scheduleRender();
|
|
630
231
|
}
|
|
631
|
-
/**
|
|
632
|
-
* Schedule a render (respects throttle)
|
|
633
|
-
*/
|
|
634
232
|
scheduleRender() {
|
|
635
|
-
if (this.disableRendering) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
// The copy will happen after actual rendering in renderNow() or copyPendingToPrevious()
|
|
233
|
+
if (this.disableRendering || this.permanentlyDisabled || this.destroyed) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (this.renderTimer) {
|
|
640
237
|
return;
|
|
641
238
|
}
|
|
642
239
|
if (this.throttle.shouldRender()) {
|
|
643
|
-
// Note: renderNow() is async but we don't await here - it's scheduled via throttle
|
|
644
|
-
// The actual render will happen asynchronously
|
|
645
240
|
void this.renderNow();
|
|
241
|
+
return;
|
|
646
242
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
* Copy pending frame to previous frame
|
|
653
|
-
*/
|
|
654
|
-
copyPendingToPrevious() {
|
|
655
|
-
// Use spread operator for efficient shallow copy
|
|
656
|
-
this.previousFrame = [...this.pendingFrame];
|
|
243
|
+
const delay = Math.max(0, this.throttle.timeUntilNextFrame());
|
|
244
|
+
this.renderTimer = setTimeout(() => {
|
|
245
|
+
this.renderTimer = null;
|
|
246
|
+
void this.renderNow();
|
|
247
|
+
}, delay);
|
|
657
248
|
}
|
|
658
|
-
/**
|
|
659
|
-
* Render immediately (bypasses throttle)
|
|
660
|
-
* Uses relative cursor movements to update only within reserved lines
|
|
661
|
-
*/
|
|
662
249
|
async renderNow() {
|
|
250
|
+
if (this.destroyed || this.permanentlyDisabled) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
663
253
|
if (this.disableRendering) {
|
|
664
254
|
this.copyPendingToPrevious();
|
|
665
255
|
return;
|
|
666
256
|
}
|
|
667
|
-
// CRITICAL: Prevent concurrent renders
|
|
668
|
-
// If we're already rendering, skip this render (the current render will show the latest state)
|
|
669
257
|
if (this.isRendering) {
|
|
670
258
|
return;
|
|
671
259
|
}
|
|
672
260
|
this.isRendering = true;
|
|
261
|
+
this.renderTimer = null;
|
|
673
262
|
try {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
//
|
|
689
|
-
this.
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
}
|
|
708
|
-
this.logToFile(`[renderNow] contentChanged=${contentChanged} heightChanged=${heightChanged} previousFrame.length=${this.previousFrame.length} pendingFrame.length=${this.pendingFrame.length} resizeJustHappened=${this.resizeJustHappened}`);
|
|
709
|
-
// CRITICAL: Skip render if content and height haven't changed (unless it's the first render)
|
|
710
|
-
// If only height changed but content hasn't, we still need to render to write newlines
|
|
711
|
-
// for the new lines. But if both are unchanged, we can skip.
|
|
712
|
-
if (this.hasRendered && !contentChanged && !heightChanged) {
|
|
713
|
-
// Content and height are static - skip render to prevent duplicates
|
|
714
|
-
this.logToFile(`[renderNow] SKIPPING render - content and height unchanged`);
|
|
715
|
-
this.isRendering = false;
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
// If content hasn't changed but height changed, we still need to render
|
|
719
|
-
// to write newlines for the new lines, but we can optimize by not re-rendering
|
|
720
|
-
// existing lines that haven't changed. However, for simplicity, we'll render
|
|
721
|
-
// everything and let the newline logic handle it.
|
|
722
|
-
// If resize happened, reset the flag after we've checked content
|
|
723
|
-
if (this.resizeJustHappened) {
|
|
724
|
-
this.logToFile(`[renderNow] NOT skipping render - contentChanged=${contentChanged} heightChanged=${heightChanged}`);
|
|
725
|
-
}
|
|
726
|
-
// Hide cursor in render buffer too (for consistency)
|
|
727
|
-
this.renderBuffer.write(ansi.HIDE_CURSOR);
|
|
728
|
-
// CRITICAL: When region exceeds viewport, we can only render visible lines
|
|
729
|
-
// Get terminal height to determine which lines are visible
|
|
730
|
-
const terminalHeight = this.getTerminalHeight();
|
|
731
|
-
const regionExceedsViewport = this.height > terminalHeight;
|
|
732
|
-
// CRITICAL: On first render (!hasRendered), we're already at the end of the region from initializeRegion().
|
|
733
|
-
// On resize (resizeJustHappened), we need to restore to saved position to get back to the region.
|
|
734
|
-
// On subsequent normal renders, restore to saved position UNLESS region exceeds viewport.
|
|
735
|
-
// When region exceeds viewport, the saved position is in scrollback, and restoring
|
|
736
|
-
// then moving up would cause us to write outside the region.
|
|
737
|
-
if (!this.hasRendered) {
|
|
738
|
-
// First render - we're at the start of the last line from initializeRegion()
|
|
739
|
-
// CRITICAL: We know the region starts at startRow (absolute terminal row)
|
|
740
|
-
// After initialization, we're at startRow + height (the line after the region)
|
|
741
|
-
// We need to position to startRow to begin rendering
|
|
742
|
-
this.logToFile(`[renderNow] First render - region starts at terminal row ${this.startRow}, currently at row ${this.startRow !== null ? this.startRow + this.height : 'unknown'} (after initialization)`);
|
|
743
|
-
// CRITICAL: Only write a newline at the bottom if the region exceeds viewport
|
|
744
|
-
// If region fits in viewport, just move up to the first line and paint
|
|
745
|
-
// The newline-at-bottom logic is only needed when expanding past viewport
|
|
746
|
-
if (regionExceedsViewport) {
|
|
747
|
-
// Region exceeds viewport: we need to render only the visible lines (last terminalHeight lines)
|
|
748
|
-
// Calculate which lines are visible
|
|
749
|
-
const visibleStartLineIndex = this.height - terminalHeight;
|
|
750
|
-
this.logToFile(`[renderNow] First render - region exceeds viewport, rendering only visible lines (startLineIndex=${visibleStartLineIndex}, linesToRender=${terminalHeight})`);
|
|
751
|
-
// Write newline at bottom first to scroll everything up
|
|
752
|
-
// This ensures we're not overwriting anything above the region
|
|
753
|
-
this.renderBuffer.write('\n');
|
|
754
|
-
// After writing newline, we're at startRow + height (one line below the region)
|
|
755
|
-
// We want to position to startRow + visibleStartLineIndex (start of visible region)
|
|
756
|
-
// So we need to move up: (startRow + height) - (startRow + visibleStartLineIndex) = height - visibleStartLineIndex = terminalHeight
|
|
757
|
-
if (terminalHeight > 0) {
|
|
758
|
-
this.renderBuffer.write(ansi.moveCursorUp(terminalHeight));
|
|
263
|
+
this.ensureTerminalState();
|
|
264
|
+
const frame = this.buildViewportFrame();
|
|
265
|
+
// CRITICAL: If previousViewportFrame is empty (e.g., on resize), do a full redraw
|
|
266
|
+
// On resize, viewport dimensions change and content may wrap differently,
|
|
267
|
+
// causing the viewport to show different logical lines. Since we control all rendering,
|
|
268
|
+
// we can simply clear and redraw the entire viewport fresh.
|
|
269
|
+
if (this.previousViewportFrame.length === 0) {
|
|
270
|
+
const viewportHeight = Math.max(1, this.viewportHeight);
|
|
271
|
+
const normalized = this.getEffectiveFrame();
|
|
272
|
+
this.logToFile(`[renderNow] FULL REDRAW - viewportHeight: ${viewportHeight}, frame.length: ${frame.length}, normalized.length: ${normalized.length}, height: ${this.height}`);
|
|
273
|
+
this.logToFile(`[renderNow] Frame content (first 5): ${frame.slice(0, 5).map((l, i) => `[${i}]: "${l.substring(0, 50)}"`).join(', ')}`);
|
|
274
|
+
this.logToFile(`[renderNow] Frame content (last 5): ${frame.slice(-5).map((l, i) => `[${frame.length - 5 + i}]: "${l.substring(0, 50)}"`).join(', ')}`);
|
|
275
|
+
// Full redraw: clear and write all viewport lines fresh
|
|
276
|
+
// Instead of ERASE_SCREEN (which might have issues during resize),
|
|
277
|
+
// we clear each line individually for more reliable behavior
|
|
278
|
+
this.renderBuffer.write(ansi.HIDE_CURSOR);
|
|
279
|
+
// Clear all viewport lines first (from bottom to top to avoid cursor issues)
|
|
280
|
+
this.logToFile(`[renderNow] Clearing ${viewportHeight} lines (from bottom to top)`);
|
|
281
|
+
for (let i = viewportHeight - 1; i >= 0; i--) {
|
|
282
|
+
const row = i + 1;
|
|
283
|
+
this.renderBuffer.write(ansi.moveCursorTo(1, row));
|
|
284
|
+
this.renderBuffer.write(ansi.CLEAR_LINE);
|
|
285
|
+
}
|
|
286
|
+
// Now write all lines in the frame (frame.length should equal viewportHeight)
|
|
287
|
+
const linesToWrite = Math.min(frame.length, viewportHeight);
|
|
288
|
+
this.logToFile(`[renderNow] Writing ${linesToWrite} lines to viewport`);
|
|
289
|
+
for (let i = 0; i < linesToWrite; i++) {
|
|
290
|
+
const row = i + 1;
|
|
291
|
+
const lineContent = frame[i] ?? '';
|
|
292
|
+
this.renderBuffer.write(ansi.moveCursorTo(1, row));
|
|
293
|
+
if (lineContent.length > 0) {
|
|
294
|
+
this.renderBuffer.write(this.truncateContent(lineContent, this.effectiveWidth));
|
|
295
|
+
this.renderBuffer.write(ansi.RESET);
|
|
759
296
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
else {
|
|
764
|
-
// Region fits in viewport: just move up to the first line and paint
|
|
765
|
-
// After initialization, we're at the start of the last line of the region
|
|
766
|
-
// So we need to move up (height-1) lines to get to the first line (startRow)
|
|
767
|
-
this.logToFile(`[renderNow] First render - region fits in viewport, moving up to first line and painting`);
|
|
768
|
-
this.logToFile(`[renderNow] Currently at start of last line (line ${this.height} at row ${this.startRow !== null ? this.startRow + this.height - 1 : 'unknown'}), moving up ${this.height > 1 ? this.height - 1 : 0} lines to get to first line (row ${this.startRow})`);
|
|
769
|
-
// CRITICAL: Flush any pending writes before positioning to ensure we're at the correct location
|
|
770
|
-
this.renderBuffer.flush();
|
|
771
|
-
if (this.height > 1) {
|
|
772
|
-
this.renderBuffer.write(ansi.moveCursorUp(this.height - 1));
|
|
297
|
+
// Log first few and last few lines being written
|
|
298
|
+
if (i < 3 || i >= linesToWrite - 3) {
|
|
299
|
+
this.logToFile(`[renderNow] Writing row ${row}: "${lineContent.substring(0, 60)}"`);
|
|
773
300
|
}
|
|
774
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
775
|
-
// Flush again to ensure positioning is executed before we start rendering
|
|
776
|
-
this.renderBuffer.flush();
|
|
777
|
-
this.logToFile(`[renderNow] After positioning, cursor should be at terminal row ${this.startRow} (start of region)`);
|
|
778
|
-
}
|
|
779
|
-
// CRITICAL: First render needs to actually render the content!
|
|
780
|
-
// Calculate which lines to render
|
|
781
|
-
let startLineIndex = 0;
|
|
782
|
-
let linesToRender = Math.min(this.pendingFrame.length, this.height);
|
|
783
|
-
if (regionExceedsViewport) {
|
|
784
|
-
startLineIndex = this.height - terminalHeight;
|
|
785
|
-
linesToRender = terminalHeight;
|
|
786
301
|
}
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
this.logToFile(`[renderNow] WRITING content for line ${lineIndex + 1} (region line ${lineIndex + 1}/${this.height}, visible line ${i + 1}/${linesToRender}, startLineIndex=${startLineIndex}, isFirstLine=${isFirstLine}): "${contentToWrite.substring(0, 50)}${contentToWrite.length > 50 ? '...' : ''}"`);
|
|
796
|
-
this.renderBuffer.write(contentToWrite);
|
|
797
|
-
if (!isLastLine) {
|
|
798
|
-
// Move to next line
|
|
799
|
-
this.renderBuffer.write(ansi.moveCursorDown(1));
|
|
800
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
302
|
+
// CRITICAL: Clear any lines beyond what we wrote (if viewport is larger than frame)
|
|
303
|
+
// This ensures we don't have leftover content from previous renders
|
|
304
|
+
if (linesToWrite < viewportHeight) {
|
|
305
|
+
this.logToFile(`[renderNow] Clearing ${viewportHeight - linesToWrite} extra lines (${linesToWrite} to ${viewportHeight - 1})`);
|
|
306
|
+
for (let i = linesToWrite; i < viewportHeight; i++) {
|
|
307
|
+
const row = i + 1;
|
|
308
|
+
this.renderBuffer.write(ansi.moveCursorTo(1, row));
|
|
309
|
+
this.renderBuffer.write(ansi.CLEAR_LINE);
|
|
801
310
|
}
|
|
802
311
|
}
|
|
803
|
-
// After rendering, move to top-left and save cursor position
|
|
804
|
-
const linesToMoveUp = linesToRender > 1 ? linesToRender - 1 : 0;
|
|
805
|
-
if (linesToMoveUp > 0) {
|
|
806
|
-
this.renderBuffer.write(ansi.moveCursorUp(linesToMoveUp));
|
|
807
|
-
}
|
|
808
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
809
312
|
this.renderBuffer.flush();
|
|
810
|
-
this.
|
|
811
|
-
this.savedCursorPosition = true;
|
|
812
|
-
// Track visible region top row for region-exceeds-viewport case
|
|
813
|
-
if (regionExceedsViewport && this.startRow !== null) {
|
|
814
|
-
this.visibleRegionTopRow = this.startRow + startLineIndex;
|
|
815
|
-
this.logToFile(`[renderNow] First render - region exceeds viewport, set visibleRegionTopRow=${this.visibleRegionTopRow}`);
|
|
816
|
-
}
|
|
817
|
-
else if (!regionExceedsViewport && this.startRow !== null) {
|
|
818
|
-
this.visibleRegionTopRow = this.startRow;
|
|
819
|
-
}
|
|
820
|
-
this.logToFile(`[renderNow] Saved cursor at top-left${regionExceedsViewport ? ' of visible region' : ' of region'} (first render)`);
|
|
821
|
-
// Move visible cursor to after the region
|
|
822
|
-
this.stdout.write(ansi.moveCursorDown(linesToRender));
|
|
823
|
-
this.stdout.write(ansi.MOVE_TO_START_OF_LINE);
|
|
824
|
-
this.stdout.write(ansi.SHOW_CURSOR);
|
|
825
|
-
this.logToFile(`[renderNow] Moved visible cursor DOWN ${linesToRender} lines to position AFTER region`);
|
|
826
|
-
// Copy pending to previous and mark as rendered
|
|
827
|
-
this.copyPendingToPrevious();
|
|
828
|
-
this.lastRenderedHeight = this.height;
|
|
829
|
-
this.hasRendered = true;
|
|
830
|
-
this.renderScheduled = false;
|
|
831
|
-
this.isRendering = false;
|
|
832
|
-
return;
|
|
833
|
-
}
|
|
834
|
-
else if (this.resizeJustHappened) {
|
|
835
|
-
// After resize - DON'T restore cursor position because it might be invalid
|
|
836
|
-
// The terminal may have scrolled or the viewport changed, making the saved
|
|
837
|
-
// position wrong. Instead, we'll position from the current location or use
|
|
838
|
-
// relative movements. The actual positioning will happen in the region-exceeds-viewport
|
|
839
|
-
// or region-fits logic below.
|
|
840
|
-
this.logToFile(`[renderNow] After resize - NOT restoring cursor (position may be invalid, will recalculate)`);
|
|
841
|
-
// Don't restore cursor - let the positioning logic below handle it
|
|
842
|
-
// This prevents writing content in the wrong place
|
|
843
|
-
// CRITICAL: Mark that we just had a resize so positioning logic knows not to restore cursor
|
|
844
|
-
// We'll reset this flag after positioning is done
|
|
845
|
-
// CRITICAL: On resize, we need to re-initialize positioning, but DON'T reset lastRenderedHeight
|
|
846
|
-
// We need to keep the previous height so we can correctly calculate if height increased
|
|
847
|
-
// Only write newlines if height actually increased from before resize
|
|
848
|
-
// NOTE: Don't reset resizeJustHappened yet - we need it for the positioning logic below
|
|
313
|
+
this.hideCursor();
|
|
849
314
|
}
|
|
850
315
|
else {
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
let startLineIndex = 0;
|
|
854
|
-
let linesToRender = Math.min(this.pendingFrame.length, this.height);
|
|
855
|
-
if (regionExceedsViewport) {
|
|
856
|
-
// Region exceeds viewport: render only visible lines (last terminalHeight lines)
|
|
857
|
-
startLineIndex = this.height - terminalHeight;
|
|
858
|
-
linesToRender = terminalHeight;
|
|
859
|
-
this.logToFile(`[renderNow] Region exceeds viewport: height=${this.height} terminalHeight=${terminalHeight} startLineIndex=${startLineIndex} linesToRender=${linesToRender}`);
|
|
860
|
-
}
|
|
861
|
-
// Track current terminal row as we render (helps detect bottom of viewport)
|
|
862
|
-
let currentTerminalRow = null;
|
|
863
|
-
// Unified positioning logic for both region-fits and region-exceeds-viewport
|
|
864
|
-
if (this.visibleRegionTopRow !== null) {
|
|
865
|
-
currentTerminalRow = this.visibleRegionTopRow;
|
|
866
|
-
this.logToFile(`[renderNow] Using tracked visibleRegionTopRow=${this.visibleRegionTopRow}`);
|
|
867
|
-
}
|
|
868
|
-
else if (regionExceedsViewport) {
|
|
869
|
-
// Estimate for region-exceeds-viewport
|
|
870
|
-
currentTerminalRow = terminalHeight - linesToRender + 1;
|
|
871
|
-
this.logToFile(`[renderNow] Estimating currentTerminalRow=${currentTerminalRow} (terminalHeight=${terminalHeight} - linesToRender=${linesToRender} + 1)`);
|
|
872
|
-
}
|
|
873
|
-
else if (this.startRow !== null) {
|
|
874
|
-
// Estimate for region-fits
|
|
875
|
-
currentTerminalRow = this.startRow;
|
|
876
|
-
}
|
|
877
|
-
// Position cursor: use saved position if available, otherwise fallback
|
|
878
|
-
if (this.savedCursorPosition && !this.resizeJustHappened) {
|
|
879
|
-
// Use saved cursor position (at top-left of region/visible region from previous render)
|
|
880
|
-
this.logToFile(`[renderNow] Subsequent render - RESTORING cursor (at top-left${regionExceedsViewport ? ' of visible region' : ' of region'})`);
|
|
881
|
-
this.renderBuffer.write(ansi.RESTORE_CURSOR);
|
|
882
|
-
this.clearCurrentLine();
|
|
883
|
-
// Update tracked position if we don't have it
|
|
884
|
-
if (this.visibleRegionTopRow === null && currentTerminalRow !== null) {
|
|
885
|
-
this.visibleRegionTopRow = currentTerminalRow;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
else if (this.startRow !== null && !regionExceedsViewport) {
|
|
889
|
-
// Fallback for region-fits: use startRow (only on first subsequent render before we've saved)
|
|
890
|
-
this.logToFile(`[renderNow] Subsequent render - no saved cursor, using startRow=${this.startRow} as fallback`);
|
|
891
|
-
this.renderBuffer.write(ansi.moveCursorTo(this.startRow, 1));
|
|
892
|
-
this.visibleRegionTopRow = this.startRow;
|
|
893
|
-
currentTerminalRow = this.startRow;
|
|
894
|
-
}
|
|
895
|
-
else {
|
|
896
|
-
// Fallback: relative positioning
|
|
897
|
-
this.logToFile(`[renderNow] Subsequent render - no saved cursor, using relative positioning`);
|
|
898
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
899
|
-
if (linesToRender > 1) {
|
|
900
|
-
this.renderBuffer.write(ansi.moveCursorUp(linesToRender - 1));
|
|
901
|
-
}
|
|
902
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
903
|
-
if (!regionExceedsViewport) {
|
|
904
|
-
currentTerminalRow = null;
|
|
905
|
-
this.visibleRegionTopRow = null;
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
// CRITICAL: If height changed, adjust saved position (only for region-fits)
|
|
909
|
-
if (!regionExceedsViewport && this.lastRenderedHeight !== this.height && this.lastRenderedHeight > 0) {
|
|
910
|
-
const heightDiff = this.height - this.lastRenderedHeight;
|
|
911
|
-
if (heightDiff > 0) {
|
|
912
|
-
this.renderBuffer.write(ansi.moveCursorDown(heightDiff));
|
|
913
|
-
this.renderBuffer.write(ansi.SAVE_CURSOR);
|
|
914
|
-
}
|
|
915
|
-
else if (heightDiff < 0) {
|
|
916
|
-
this.renderBuffer.write(ansi.moveCursorUp(-heightDiff));
|
|
917
|
-
this.renderBuffer.write(ansi.SAVE_CURSOR);
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
// Calculate newline logic
|
|
921
|
-
const heightIncreased = this.hasRendered && this.lastRenderedHeight > 0 && this.height > this.lastRenderedHeight;
|
|
922
|
-
const newLinesCount = heightIncreased ? this.height - this.lastRenderedHeight : 0;
|
|
923
|
-
const shouldWriteNewlines = newLinesCount > 0 && heightIncreased;
|
|
924
|
-
this.logToFile(`[renderNow] hasRendered=${this.hasRendered} height=${this.height} lastRenderedHeight=${this.lastRenderedHeight} heightIncreased=${heightIncreased} newLinesCount=${newLinesCount} shouldWriteNewlines=${shouldWriteNewlines}`);
|
|
925
|
-
// CRITICAL: When region exceeds viewport and we're adding a new line at bottom:
|
|
926
|
-
// Write the top line that will scroll out BEFORE the loop (so it's in scrollback)
|
|
927
|
-
const lastLineIndex = startLineIndex + linesToRender - 1;
|
|
928
|
-
const isLastLineNew = shouldWriteNewlines && lastLineIndex === (this.height - 1);
|
|
929
|
-
const willScroll = regionExceedsViewport && isLastLineNew && startLineIndex > 0;
|
|
930
|
-
if (willScroll) {
|
|
931
|
-
const topLineIndex = startLineIndex;
|
|
932
|
-
const topLineContent = this.pendingFrame[topLineIndex] || '';
|
|
933
|
-
this.logToFile(`[renderNow] About to write newline at bottom - first writing top line ${topLineIndex + 1} that will scroll out`);
|
|
934
|
-
this.clearCurrentLine();
|
|
935
|
-
const topContentToWrite = this.truncateContent(topLineContent, this.width);
|
|
936
|
-
this.renderBuffer.write(topContentToWrite);
|
|
937
|
-
this.renderBuffer.write(ansi.moveCursorDown(1));
|
|
938
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
939
|
-
if (currentTerminalRow !== null) {
|
|
940
|
-
currentTerminalRow = currentTerminalRow + 1;
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
// Unified rendering loop for both region-fits and region-exceeds-viewport
|
|
944
|
-
const loopStart = willScroll ? 1 : 0;
|
|
945
|
-
let actuallyWroteNewlineAtBottom = false;
|
|
946
|
-
let wroteNewlineForLastLine = false; // Track if we wrote newline for last line (region-fits case)
|
|
947
|
-
for (let i = loopStart; i < linesToRender; i++) {
|
|
948
|
-
const lineIndex = startLineIndex + i;
|
|
949
|
-
const content = this.pendingFrame[lineIndex] || '';
|
|
950
|
-
const isLastLine = (i === linesToRender - 1);
|
|
951
|
-
// CRITICAL: Check if this line is one of the new lines: lineIndex >= (this.height - newLinesCount)
|
|
952
|
-
// Only write newline if region is expanding (shouldWriteNewlines is true)
|
|
953
|
-
const isNewLine = shouldWriteNewlines && lineIndex >= (this.height - newLinesCount);
|
|
954
|
-
// CRITICAL: Always clear the line BEFORE writing
|
|
955
|
-
// This prevents any leftover content from causing duplicates
|
|
956
|
-
this.clearCurrentLine();
|
|
957
|
-
// CRITICAL: For region-exceeds-viewport with new line at bottom, write newline FIRST to trigger scroll
|
|
958
|
-
// For region-fits, write content first, then newline (normal order)
|
|
959
|
-
const isNewLineAtBottom = isNewLine && isLastLine;
|
|
960
|
-
if (isNewLineAtBottom && regionExceedsViewport) {
|
|
961
|
-
// Region exceeds viewport: write newline FIRST to trigger scroll, then query cursor
|
|
962
|
-
// CRITICAL: Only query cursor if we're actually at the bottom of the viewport (will trigger scrolling)
|
|
963
|
-
const isAtBottomOfViewport = currentTerminalRow !== null && currentTerminalRow === terminalHeight;
|
|
964
|
-
this.logToFile(`[renderNow] WRITING newline FIRST for new line ${lineIndex + 1} at bottom (region expanding${isAtBottomOfViewport ? ', will trigger scroll' : ''})`);
|
|
965
|
-
this.renderBuffer.write('\n');
|
|
966
|
-
if (currentTerminalRow !== null) {
|
|
967
|
-
currentTerminalRow = currentTerminalRow + 1;
|
|
968
|
-
}
|
|
969
|
-
actuallyWroteNewlineAtBottom = true;
|
|
970
|
-
if (isAtBottomOfViewport) {
|
|
971
|
-
// CRITICAL: Flush buffer to ensure newline is written before querying
|
|
972
|
-
this.renderBuffer.flush();
|
|
973
|
-
// CRITICAL: Query cursor position to confirm terminal has finished scrolling
|
|
974
|
-
// Terminal scrolling may be asynchronous, so we need to wait for it to complete
|
|
975
|
-
this.logToFile(`[renderNow] At bottom of viewport - querying cursor to confirm scroll completed...`);
|
|
976
|
-
try {
|
|
977
|
-
const pos = await queryCursorPosition(500);
|
|
978
|
-
this.logToFile(`[renderNow] ✓ Cursor query after newline: row=${pos.row}, col=${pos.col} (expected row=${terminalHeight + 1} if scroll occurred)`);
|
|
979
|
-
if (pos.row === terminalHeight + 1) {
|
|
980
|
-
// Scroll happened - move up one to get back to the last visible line
|
|
981
|
-
this.renderBuffer.write(ansi.moveCursorUp(1));
|
|
982
|
-
this.visibleRegionTopRow = terminalHeight - (linesToRender - 1);
|
|
983
|
-
this.logToFile(`[renderNow] Scroll confirmed - updated visibleRegionTopRow=${this.visibleRegionTopRow}`);
|
|
984
|
-
}
|
|
985
|
-
else {
|
|
986
|
-
this.logToFile(`[renderNow] WARNING: Scroll did not occur (cursor at row=${pos.row}, expected ${terminalHeight + 1})`);
|
|
987
|
-
this.visibleRegionTopRow = terminalHeight - (linesToRender - 1);
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
catch (err) {
|
|
991
|
-
this.logToFile(`[renderNow] ✗ Cursor query failed: ${err instanceof Error ? err.message : String(err)}, proceeding anyway`);
|
|
992
|
-
this.renderBuffer.write(ansi.moveCursorUp(1));
|
|
993
|
-
this.visibleRegionTopRow = terminalHeight - (linesToRender - 1);
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
else {
|
|
997
|
-
// Not at bottom of viewport - just move up to get back to the line
|
|
998
|
-
this.renderBuffer.write(ansi.moveCursorUp(1));
|
|
999
|
-
this.logToFile(`[renderNow] Not at bottom of viewport (currentTerminalRow was ${currentTerminalRow !== null ? currentTerminalRow - 1 : 'null'}), moved up 1 to get back to line`);
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
// CRITICAL: Trust the grid layout system - it should ensure content fits within width
|
|
1003
|
-
const contentToWrite = this.truncateContent(content, this.width);
|
|
1004
|
-
// Write content (truncated only if significantly too long)
|
|
1005
|
-
const isFirstLine = lineIndex === 0;
|
|
1006
|
-
this.logToFile(`[renderNow] WRITING content for line ${lineIndex + 1} (region line ${lineIndex + 1}/${this.height}, visible line ${i + 1}/${linesToRender}, startLineIndex=${startLineIndex}, isFirstLine=${isFirstLine}): "${contentToWrite.substring(0, 50)}${contentToWrite.length > 50 ? '...' : ''}"`);
|
|
1007
|
-
this.renderBuffer.write(contentToWrite);
|
|
1008
|
-
// CRITICAL: Write newline for new lines (handled differently for last line vs non-last line)
|
|
1009
|
-
if (isNewLine && !isLastLine) {
|
|
1010
|
-
// New line, not at bottom: write newline normally
|
|
1011
|
-
this.logToFile(`[renderNow] WRITING newline for line ${lineIndex + 1} (new line, region expanding, not at bottom)`);
|
|
1012
|
-
this.renderBuffer.write('\n');
|
|
1013
|
-
if (currentTerminalRow !== null) {
|
|
1014
|
-
currentTerminalRow = currentTerminalRow + 1;
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
else if (isNewLineAtBottom && !regionExceedsViewport) {
|
|
1018
|
-
// New line at bottom, region fits: write newline after content (normal order)
|
|
1019
|
-
this.logToFile(`[renderNow] WRITING newline for last line ${lineIndex + 1} (new line, region expanding, region fits)`);
|
|
1020
|
-
this.renderBuffer.write('\n');
|
|
1021
|
-
wroteNewlineForLastLine = true;
|
|
1022
|
-
}
|
|
1023
|
-
else if (!isLastLine && !isNewLine) {
|
|
1024
|
-
// Not the last line and not a new line: move to next line
|
|
1025
|
-
this.renderBuffer.write(ansi.moveCursorDown(1));
|
|
1026
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
1027
|
-
}
|
|
1028
|
-
// After rendering last line, we're at the end of the last line
|
|
1029
|
-
// We'll move to top-left after the loop
|
|
1030
|
-
}
|
|
1031
|
-
// After rendering, move to top-left of VISIBLE region and save that position
|
|
1032
|
-
// This ensures we always know where the visible region starts for the next render
|
|
1033
|
-
const lastRenderedLineIndex = startLineIndex + linesToRender - 1;
|
|
1034
|
-
const isLastLineOfRegion = lastRenderedLineIndex === (this.height - 1);
|
|
1035
|
-
this.logToFile(`[renderNow] After rendering loop: lastRenderedLineIndex=${lastRenderedLineIndex} (region line ${lastRenderedLineIndex + 1}/${this.height}), isLastLineOfRegion=${isLastLineOfRegion}, actuallyWroteNewlineAtBottom=${actuallyWroteNewlineAtBottom}, terminalHeight=${terminalHeight}`);
|
|
1036
|
-
// CRITICAL: After writing a newline at the bottom, the terminal scrolled
|
|
1037
|
-
// The top of the visible region has moved up by one line
|
|
1038
|
-
// So we need to adjust how many lines we move up to get to the top-left
|
|
1039
|
-
// If we wrote a newline, we're at the end of the last rendered line
|
|
1040
|
-
// The top of the visible region is now (linesToRender - 1) lines up (because it scrolled)
|
|
1041
|
-
// So we should move up (linesToRender - 1) lines, not linesToRender lines
|
|
1042
|
-
if (actuallyWroteNewlineAtBottom) {
|
|
1043
|
-
// We wrote a newline at the bottom, terminal scrolled
|
|
1044
|
-
// We're at the end of the last rendered line (which is now below the viewport after scroll)
|
|
1045
|
-
// The top of the visible region moved up by 1 line due to the scroll
|
|
1046
|
-
// So if we were at row X, we're now at row X+1, and the top moved from row Y to row Y+1
|
|
1047
|
-
// We need to move up (linesToRender - 1) lines to get to the new top-left
|
|
1048
|
-
this.logToFile(`[renderNow] Wrote newline at bottom of viewport (viewport line ${linesToRender}/${linesToRender}) - terminal scrolled, top of visible region moved up 1 line`);
|
|
1049
|
-
this.logToFile(`[renderNow] Moving up ${linesToRender - 1} lines to get to top-left (accounting for scroll)`);
|
|
1050
|
-
if (linesToRender > 1) {
|
|
1051
|
-
this.renderBuffer.write(ansi.moveCursorUp(linesToRender - 1));
|
|
1052
|
-
}
|
|
1053
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
1054
|
-
// CRITICAL: Flush buffer BEFORE saving cursor so all movements are executed
|
|
1055
|
-
this.renderBuffer.flush();
|
|
1056
|
-
// Write SAVE_CURSOR directly to stdout (not buffer) so it executes immediately
|
|
1057
|
-
this.stdout.write(ansi.SAVE_CURSOR);
|
|
1058
|
-
this.savedCursorPosition = true;
|
|
1059
|
-
this.logToFile(`[renderNow] Saved cursor at top-left of visible region (startLineIndex=${startLineIndex + 1} after scroll)`);
|
|
1060
|
-
}
|
|
1061
|
-
else if (wroteNewlineForLastLine) {
|
|
1062
|
-
// Wrote newline for last line (region-fits case) - we're at the start of the line after the region
|
|
1063
|
-
// So we need to move up linesToRender lines to get to the top-left
|
|
1064
|
-
this.logToFile(`[renderNow] Wrote newline for last line (region-fits) - moving up ${linesToRender} lines to get to top-left`);
|
|
1065
|
-
if (linesToRender > 0) {
|
|
1066
|
-
this.renderBuffer.write(ansi.moveCursorUp(linesToRender));
|
|
1067
|
-
}
|
|
1068
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
1069
|
-
this.renderBuffer.flush();
|
|
1070
|
-
this.stdout.write(ansi.SAVE_CURSOR);
|
|
1071
|
-
this.savedCursorPosition = true;
|
|
1072
|
-
this.logToFile(`[renderNow] Saved cursor at top-left of region`);
|
|
1073
|
-
}
|
|
1074
|
-
else {
|
|
1075
|
-
// No newline written, normal case - move up (linesToRender - 1) lines
|
|
1076
|
-
this.logToFile(`[renderNow] No newline written at bottom - normal case, moving up ${linesToRender - 1} lines`);
|
|
1077
|
-
this.moveToTopLeftAndSave(linesToRender, regionExceedsViewport ? `of visible region (startLineIndex=${startLineIndex})` : `of region`);
|
|
1078
|
-
}
|
|
1079
|
-
// CRITICAL: Move the VISIBLE cursor to the line AFTER the region (below it)
|
|
1080
|
-
// This prevents user input from overwriting the region content
|
|
1081
|
-
// We saved the position at top-left for our internal tracking, but the visible cursor should be out of the way
|
|
1082
|
-
this.stdout.write(ansi.moveCursorDown(linesToRender));
|
|
1083
|
-
this.stdout.write(ansi.MOVE_TO_START_OF_LINE);
|
|
1084
|
-
this.stdout.write(ansi.SHOW_CURSOR);
|
|
1085
|
-
this.logToFile(`[renderNow] Moved visible cursor DOWN ${linesToRender} lines to position AFTER region (so user input won't interfere)`);
|
|
316
|
+
// Normal diff-based rendering (for incremental updates)
|
|
317
|
+
this.applyDiff(frame);
|
|
1086
318
|
}
|
|
1087
|
-
|
|
1088
|
-
// Since we've already cleared and re-rendered all lines, deletions are handled
|
|
1089
|
-
// by the fact that those lines are no longer in pendingFrame
|
|
1090
|
-
// No additional action needed - the lines are already cleared
|
|
1091
|
-
// CRITICAL: After rendering, we've saved the cursor at the top-left of the region
|
|
1092
|
-
// This ensures we always know where the region starts for the next render
|
|
1093
|
-
// The cursor position is saved at the first line, start of line, of the region
|
|
1094
|
-
// This is more reliable than saving at the end, especially after resize/scroll
|
|
1095
|
-
// Copy pending to previous
|
|
319
|
+
this.previousViewportFrame = [...frame];
|
|
1096
320
|
this.copyPendingToPrevious();
|
|
1097
|
-
// Update last rendered height for next render
|
|
1098
321
|
this.lastRenderedHeight = this.height;
|
|
1099
|
-
this.hasRendered = true; // Mark that we've rendered at least once
|
|
1100
|
-
// CRITICAL: Reset resize flag after rendering is complete
|
|
1101
|
-
// This ensures the flag is only active during the render that handles the resize
|
|
1102
|
-
if (this.resizeJustHappened) {
|
|
1103
|
-
this.logToFile(`[renderNow] RESETTING resizeJustHappened=false (after render complete)`);
|
|
1104
|
-
this.resizeJustHappened = false;
|
|
1105
|
-
}
|
|
1106
|
-
this.renderScheduled = false;
|
|
1107
322
|
}
|
|
1108
323
|
finally {
|
|
1109
324
|
this.isRendering = false;
|
|
1110
325
|
}
|
|
1111
326
|
}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
327
|
+
copyPendingToPrevious() {
|
|
328
|
+
const normalized = this.getEffectiveFrame();
|
|
329
|
+
this.previousFrame = [...normalized];
|
|
330
|
+
}
|
|
331
|
+
buildViewportFrame() {
|
|
332
|
+
const viewportHeight = Math.max(1, this.viewportHeight);
|
|
333
|
+
const normalized = this.getEffectiveFrame();
|
|
334
|
+
const visibleLines = Math.min(viewportHeight, normalized.length);
|
|
335
|
+
const frame = new Array(viewportHeight).fill('');
|
|
336
|
+
if (visibleLines === 0) {
|
|
337
|
+
this.logToFile(`[buildViewportFrame] No visible lines - normalized.length: ${normalized.length}, viewportHeight: ${viewportHeight}`);
|
|
338
|
+
return frame;
|
|
339
|
+
}
|
|
340
|
+
const startIndex = normalized.length > viewportHeight
|
|
341
|
+
? normalized.length - viewportHeight
|
|
342
|
+
: 0;
|
|
343
|
+
this.logToFile(`[buildViewportFrame] normalized.length: ${normalized.length}, viewportHeight: ${viewportHeight}, startIndex: ${startIndex}, visibleLines: ${visibleLines}`);
|
|
344
|
+
for (let i = 0; i < visibleLines; i++) {
|
|
345
|
+
frame[i] = normalized[startIndex + i];
|
|
346
|
+
}
|
|
347
|
+
// Log what we're putting in the frame
|
|
348
|
+
if (startIndex > 0 || visibleLines < normalized.length) {
|
|
349
|
+
this.logToFile(`[buildViewportFrame] Frame shows lines ${startIndex + 1} to ${startIndex + visibleLines} of ${normalized.length} total lines`);
|
|
350
|
+
this.logToFile(`[buildViewportFrame] First frame line: "${frame[0]?.substring(0, 50)}"`);
|
|
351
|
+
this.logToFile(`[buildViewportFrame] Last frame line: "${frame[visibleLines - 1]?.substring(0, 50)}"`);
|
|
352
|
+
}
|
|
353
|
+
return frame;
|
|
354
|
+
}
|
|
355
|
+
mapLineToViewportRow(lineNumber) {
|
|
356
|
+
const viewportHeight = Math.max(1, this.viewportHeight);
|
|
357
|
+
const visibleStart = Math.max(1, this.height - viewportHeight + 1);
|
|
358
|
+
const visibleEnd = visibleStart + viewportHeight - 1;
|
|
359
|
+
if (lineNumber < visibleStart || lineNumber > visibleEnd) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return lineNumber - visibleStart + 1;
|
|
363
|
+
}
|
|
364
|
+
hideCursor() {
|
|
365
|
+
if (this.permanentlyDisabled) {
|
|
366
|
+
return;
|
|
1121
367
|
}
|
|
1122
|
-
|
|
368
|
+
this.stdout.write(ansi.HIDE_CURSOR);
|
|
369
|
+
this.cursorVisible = false;
|
|
1123
370
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
this.
|
|
371
|
+
showCursorAt(lineNumber, column) {
|
|
372
|
+
if (this.permanentlyDisabled) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
this.ensureTerminalState();
|
|
376
|
+
const row = this.mapLineToViewportRow(lineNumber);
|
|
377
|
+
if (row === null) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const col = Math.max(1, Math.min(column, this.viewportWidth));
|
|
381
|
+
this.stdout.write(ansi.moveCursorTo(col, row));
|
|
382
|
+
this.stdout.write(ansi.SHOW_CURSOR);
|
|
383
|
+
this.cursorVisible = true;
|
|
1129
384
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
385
|
+
getEffectiveFrame() {
|
|
386
|
+
const target = Math.max(0, this.height);
|
|
387
|
+
const frame = this.pendingFrame.slice(0, target);
|
|
388
|
+
while (frame.length < target) {
|
|
389
|
+
frame.push('');
|
|
390
|
+
}
|
|
391
|
+
// Log if frame seems corrupted (has content but in wrong order)
|
|
392
|
+
if (frame.length > 0 && frame.some((line, i) => line.length > 0)) {
|
|
393
|
+
const nonEmptyLines = frame.map((line, i) => ({ index: i, content: line.substring(0, 50) })).filter(l => l.content.length > 0);
|
|
394
|
+
if (nonEmptyLines.length > 1) {
|
|
395
|
+
// Check if first non-empty line looks like it should be later (e.g., contains "Press SPACEBAR" or is a border)
|
|
396
|
+
const firstNonEmpty = nonEmptyLines[0];
|
|
397
|
+
if (firstNonEmpty.content.includes('Press SPACEBAR') || firstNonEmpty.content.includes('╰') || firstNonEmpty.content.includes('│')) {
|
|
398
|
+
this.logToFile(`[getEffectiveFrame] WARNING: Frame might be corrupted - first non-empty line at index ${firstNonEmpty.index}: "${firstNonEmpty.content}"`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
1136
401
|
}
|
|
1137
|
-
|
|
402
|
+
return frame;
|
|
1138
403
|
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
404
|
+
applyDiff(nextFrame) {
|
|
405
|
+
const ops = diffFrames(this.previousViewportFrame, nextFrame);
|
|
406
|
+
let wrote = false;
|
|
407
|
+
for (const op of ops) {
|
|
408
|
+
if (op.type === 'no_change') {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (!wrote) {
|
|
412
|
+
this.renderBuffer.write(ansi.HIDE_CURSOR);
|
|
413
|
+
wrote = true;
|
|
414
|
+
}
|
|
415
|
+
const row = op.line + 1;
|
|
416
|
+
if (op.type === 'delete_line') {
|
|
417
|
+
// Delete line: clear it completely using CLEAR_LINE
|
|
418
|
+
this.renderBuffer.write(ansi.moveCursorTo(1, row));
|
|
419
|
+
this.renderBuffer.write(ansi.CLEAR_LINE);
|
|
420
|
+
// Note: After delete, all lines below shift up, so they'll be redrawn by subsequent ops
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
// Full line update: clear and redraw
|
|
424
|
+
const lineContent = nextFrame[op.line] ?? op.content ?? '';
|
|
425
|
+
this.renderBuffer.write(ansi.moveCursorTo(1, row));
|
|
426
|
+
this.renderBuffer.write(ansi.CLEAR_LINE);
|
|
427
|
+
if (lineContent.length > 0) {
|
|
428
|
+
this.renderBuffer.write(this.truncateContent(lineContent, this.effectiveWidth));
|
|
429
|
+
this.renderBuffer.write(ansi.RESET);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (wrote) {
|
|
434
|
+
this.renderBuffer.flush();
|
|
435
|
+
this.hideCursor();
|
|
1151
436
|
}
|
|
1152
|
-
// Re-enable rendering
|
|
1153
|
-
this.disableRendering = wasRenderingDisabled;
|
|
1154
437
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
438
|
+
truncateContent(content, maxWidth) {
|
|
439
|
+
return truncateToWidth(content, maxWidth);
|
|
440
|
+
}
|
|
441
|
+
initializeTerminalState() {
|
|
442
|
+
if (this.inAlternateScreen) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
this.stdout.write(ansi.ENTER_ALTERNATE_SCREEN);
|
|
446
|
+
this.stdout.write(ansi.DISABLE_AUTO_WRAP);
|
|
447
|
+
this.stdout.write(ansi.ERASE_SCREEN);
|
|
448
|
+
this.stdout.write(ansi.moveCursorTo(1, 1));
|
|
449
|
+
this.autoWrapDisabled = true;
|
|
450
|
+
this.inAlternateScreen = true;
|
|
451
|
+
}
|
|
452
|
+
ensureTerminalState() {
|
|
453
|
+
if (!this.inAlternateScreen) {
|
|
454
|
+
this.initializeTerminalState();
|
|
455
|
+
}
|
|
456
|
+
if (!this.autoWrapDisabled) {
|
|
457
|
+
this.stdout.write(ansi.DISABLE_AUTO_WRAP);
|
|
458
|
+
this.autoWrapDisabled = true;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
leaveAlternateScreen(clearFirst) {
|
|
462
|
+
if (!this.inAlternateScreen) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
this.stdout.write(ansi.SHOW_CURSOR);
|
|
466
|
+
if (this.autoWrapDisabled) {
|
|
1176
467
|
this.stdout.write(ansi.ENABLE_AUTO_WRAP);
|
|
1177
468
|
this.autoWrapDisabled = false;
|
|
1178
469
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
470
|
+
this.stdout.write(ansi.EXIT_ALTERNATE_SCREEN);
|
|
471
|
+
this.inAlternateScreen = false;
|
|
472
|
+
if (!clearFirst) {
|
|
473
|
+
const finalLines = this.getEffectiveFrame();
|
|
474
|
+
// Remove trailing empty lines - don't add extra blank lines to original screen
|
|
475
|
+
let lastNonEmpty = finalLines.length;
|
|
476
|
+
while (lastNonEmpty > 0 && finalLines[lastNonEmpty - 1].trim() === '') {
|
|
477
|
+
lastNonEmpty--;
|
|
478
|
+
}
|
|
479
|
+
const trimmedLines = finalLines.slice(0, lastNonEmpty);
|
|
480
|
+
if (trimmedLines.length > 0) {
|
|
481
|
+
const output = trimmedLines.join('\n');
|
|
482
|
+
this.stdout.write(`${output}\n`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
setupResizeHandler() {
|
|
487
|
+
if (!this.stdout.isTTY || typeof this.stdout.on !== 'function') {
|
|
1181
488
|
return;
|
|
1182
489
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
this.
|
|
1186
|
-
this.
|
|
490
|
+
const handler = () => {
|
|
491
|
+
const oldViewportHeight = this.viewportHeight;
|
|
492
|
+
const oldViewportWidth = this.viewportWidth;
|
|
493
|
+
this.updateViewportMetrics();
|
|
494
|
+
const newViewportHeight = this.viewportHeight;
|
|
495
|
+
const newViewportWidth = this.viewportWidth;
|
|
496
|
+
this.logToFile(`[resize] ========================================`);
|
|
497
|
+
this.logToFile(`[resize] OLD: height=${oldViewportHeight}, width=${oldViewportWidth}`);
|
|
498
|
+
this.logToFile(`[resize] NEW: height=${newViewportHeight}, width=${newViewportWidth}`);
|
|
499
|
+
this.logToFile(`[resize] Content height: ${this.height}, pendingFrame.length: ${this.pendingFrame.length}`);
|
|
500
|
+
this.logToFile(`[resize] previousViewportFrame.length: ${this.previousViewportFrame.length}`);
|
|
501
|
+
if (this.previousViewportFrame.length > 0) {
|
|
502
|
+
this.logToFile(`[resize] Previous frame (first 3): ${this.previousViewportFrame.slice(0, 3).map((l, i) => `[${i}]: "${l.substring(0, 40)}"`).join(', ')}`);
|
|
503
|
+
this.logToFile(`[resize] Previous frame (last 3): ${this.previousViewportFrame.slice(-3).map((l, i) => `[${this.previousViewportFrame.length - 3 + i}]: "${l.substring(0, 40)}"`).join(', ')}`);
|
|
504
|
+
}
|
|
505
|
+
// CRITICAL: On resize, clear the screen and do a full redraw
|
|
506
|
+
// This bypasses the diff algorithm which can get confused when:
|
|
507
|
+
// 1. Viewport dimensions change
|
|
508
|
+
// 2. Content wraps differently, changing logical line positions
|
|
509
|
+
// 3. The viewport frame represents different logical lines
|
|
510
|
+
// Clear previousViewportFrame to signal renderNow() to do a full redraw
|
|
511
|
+
this.previousViewportFrame = [];
|
|
512
|
+
this.lastRenderedHeight = 0;
|
|
513
|
+
// Trigger re-render of all content with new width
|
|
514
|
+
// NOTE: onKeepAlive() will call reRenderLastContent() which calls flush(),
|
|
515
|
+
// so we don't need to call scheduleRender() here - that would cause double rendering
|
|
516
|
+
if (this.onKeepAlive) {
|
|
517
|
+
this.onKeepAlive();
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
this.stdout.on('resize', handler);
|
|
521
|
+
this.resizeCleanup = () => {
|
|
522
|
+
if (typeof this.stdout.off === 'function') {
|
|
523
|
+
this.stdout.off('resize', handler);
|
|
524
|
+
}
|
|
525
|
+
else if (typeof this.stdout.removeListener === 'function') {
|
|
526
|
+
this.stdout.removeListener('resize', handler);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
updateViewportMetrics() {
|
|
531
|
+
const width = this.readViewportWidth();
|
|
532
|
+
const height = this.readViewportHeight();
|
|
533
|
+
this.viewportWidth = width;
|
|
534
|
+
this.viewportHeight = height;
|
|
535
|
+
this.width = this.viewportWidth;
|
|
536
|
+
this.effectiveWidth = this.viewportWidth;
|
|
537
|
+
}
|
|
538
|
+
readViewportWidth() {
|
|
539
|
+
if (this.stdout.isTTY && typeof this.stdout.columns === 'number' && this.stdout.columns > 0) {
|
|
540
|
+
return this.stdout.columns;
|
|
1187
541
|
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
this.clear();
|
|
1191
|
-
// Render the clear immediately so previousFrame is updated
|
|
1192
|
-
await this.renderNow();
|
|
542
|
+
if (process.stdout.isTTY && typeof process.stdout.columns === 'number' && process.stdout.columns > 0) {
|
|
543
|
+
return process.stdout.columns;
|
|
1193
544
|
}
|
|
1194
|
-
if (
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
const allLinesBlank = this.previousFrame.every(line => line.trim() === '');
|
|
1199
|
-
// Only delete lines if they are blank
|
|
1200
|
-
// If clearFirst was true, we've already cleared and rendered, so previousFrame will be empty
|
|
1201
|
-
// If clearFirst was false, we check previousFrame to see if content exists
|
|
1202
|
-
if (allLinesBlank && this.height > 0) {
|
|
1203
|
-
// CRITICAL: Don't use RESTORE_CURSOR here - it might be invalid after destroy
|
|
1204
|
-
// Instead, just move to the end of the region using relative movement
|
|
1205
|
-
// We know we're at the end because that's where we save the cursor
|
|
1206
|
-
// Move to start of first line of region (go up by height)
|
|
1207
|
-
this.renderBuffer.write(ansi.moveCursorUp(this.height));
|
|
1208
|
-
// Use MOVE_TO_START_OF_LINE which is more reliable than \r
|
|
1209
|
-
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
1210
|
-
// Delete the blank lines (shifts content up if supported)
|
|
1211
|
-
// This will remove the lines from the terminal display
|
|
1212
|
-
this.renderBuffer.write(ansi.deleteLines(this.height));
|
|
1213
|
-
// Flush the cleanup
|
|
1214
|
-
this.renderBuffer.flush();
|
|
545
|
+
if (process.env.COLUMNS) {
|
|
546
|
+
const parsed = parseInt(process.env.COLUMNS, 10);
|
|
547
|
+
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
548
|
+
return parsed;
|
|
1215
549
|
}
|
|
1216
|
-
// CRITICAL: Clear any saved cursor position to prevent zsh from restoring it
|
|
1217
|
-
// Some terminals/shells (like zsh) may try to restore saved cursor positions
|
|
1218
|
-
// We don't want to leave a stale saved position that could cause issues
|
|
1219
|
-
// Note: There's no standard ANSI code to "clear" saved position, but we can
|
|
1220
|
-
// ensure we're at a known position and not relying on saved position
|
|
1221
|
-
// If lines have content, we leave them as-is - the user can see the output
|
|
1222
550
|
}
|
|
1223
|
-
|
|
1224
|
-
this.pendingFrame = [];
|
|
1225
|
-
this.previousFrame = [];
|
|
1226
|
-
this.renderBuffer.clear();
|
|
1227
|
-
// Mark as destroyed to prevent double-cleanup
|
|
1228
|
-
this.isInitialized = false;
|
|
551
|
+
return 80;
|
|
1229
552
|
}
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
553
|
+
readViewportHeight() {
|
|
554
|
+
if (this.stdout.isTTY && typeof this.stdout.rows === 'number' && this.stdout.rows > 0) {
|
|
555
|
+
return this.stdout.rows;
|
|
556
|
+
}
|
|
557
|
+
if (process.stdout.isTTY && typeof process.stdout.rows === 'number' && process.stdout.rows > 0) {
|
|
558
|
+
return process.stdout.rows;
|
|
559
|
+
}
|
|
560
|
+
return getDefaultTerminalHeight();
|
|
1233
561
|
}
|
|
1234
|
-
|
|
1235
|
-
|
|
562
|
+
resolveDebugLogPath(debugLog) {
|
|
563
|
+
if (!debugLog) {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
return path.isAbsolute(debugLog) ? debugLog : path.join(process.cwd(), debugLog);
|
|
1236
567
|
}
|
|
1237
568
|
}
|
|
1238
569
|
//# sourceMappingURL=region-renderer.js.map
|