linecraft 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +337 -64
- package/lib/api/color.d.ts +6 -0
- package/lib/api/color.d.ts.map +1 -0
- package/lib/api/color.js +9 -0
- package/lib/api/color.js.map +1 -0
- package/lib/api/color.test.d.ts +2 -0
- package/lib/api/color.test.d.ts.map +1 -0
- package/lib/api/color.test.js +23 -0
- package/lib/api/color.test.js.map +1 -0
- package/lib/api/flex.d.ts +55 -0
- package/lib/api/flex.d.ts.map +1 -0
- package/lib/api/flex.js +86 -0
- package/lib/api/flex.js.map +1 -0
- package/lib/api/flex.test.d.ts +2 -0
- package/lib/api/flex.test.d.ts.map +1 -0
- package/lib/api/flex.test.js +82 -0
- package/lib/api/flex.test.js.map +1 -0
- package/lib/api/grid.d.ts +22 -0
- package/lib/api/grid.d.ts.map +1 -0
- package/lib/api/grid.js +65 -0
- package/lib/api/grid.js.map +1 -0
- package/lib/api/grid.test.d.ts +2 -0
- package/lib/api/grid.test.d.ts.map +1 -0
- package/lib/api/grid.test.js +48 -0
- package/lib/api/grid.test.js.map +1 -0
- package/lib/components/base.d.ts +53 -0
- package/lib/components/base.d.ts.map +1 -0
- package/lib/components/base.js +47 -0
- package/lib/components/base.js.map +1 -0
- package/lib/components/col.d.ts +35 -0
- package/lib/components/col.d.ts.map +1 -0
- package/lib/components/col.js +168 -0
- package/lib/components/col.js.map +1 -0
- package/lib/components/col.test.d.ts +2 -0
- package/lib/components/col.test.d.ts.map +1 -0
- package/lib/components/col.test.js +96 -0
- package/lib/components/col.test.js.map +1 -0
- package/lib/components/index.d.ts +3 -0
- package/lib/components/index.d.ts.map +1 -0
- package/lib/components/index.js +5 -0
- package/lib/components/index.js.map +1 -0
- package/lib/components/progress-bar-grid.d.ts +20 -0
- package/lib/components/progress-bar-grid.d.ts.map +1 -0
- package/lib/components/progress-bar-grid.js +44 -0
- package/lib/components/progress-bar-grid.js.map +1 -0
- package/lib/components/progress-bar-grid.test.d.ts +2 -0
- package/lib/components/progress-bar-grid.test.d.ts.map +1 -0
- package/lib/components/progress-bar-grid.test.js +101 -0
- package/lib/components/progress-bar-grid.test.js.map +1 -0
- package/lib/components/progress-bar.d.ts +26 -3
- package/lib/components/progress-bar.d.ts.map +1 -1
- package/lib/components/progress-bar.js +62 -6
- package/lib/components/progress-bar.js.map +1 -1
- package/lib/components/progress-bar.test.d.ts +2 -0
- package/lib/components/progress-bar.test.d.ts.map +1 -0
- package/lib/components/progress-bar.test.js +153 -0
- package/lib/components/progress-bar.test.js.map +1 -0
- package/lib/components/prompt.d.ts +14 -0
- package/lib/components/prompt.d.ts.map +1 -0
- package/lib/components/prompt.js +77 -0
- package/lib/components/prompt.js.map +1 -0
- package/lib/components/renderable.d.ts +42 -0
- package/lib/components/renderable.d.ts.map +1 -0
- package/lib/components/renderable.js +225 -0
- package/lib/components/renderable.js.map +1 -0
- package/lib/components/spinner.d.ts +2 -2
- package/lib/components/spinner.d.ts.map +1 -1
- package/lib/components/spinner.js +3 -1
- package/lib/components/spinner.js.map +1 -1
- package/lib/components/spinner.test.d.ts +2 -0
- package/lib/components/spinner.test.d.ts.map +1 -0
- package/lib/components/spinner.test.js +168 -0
- package/lib/components/spinner.test.js.map +1 -0
- package/lib/components/style.d.ts +16 -0
- package/lib/components/style.d.ts.map +1 -0
- package/lib/components/style.js +72 -0
- package/lib/components/style.js.map +1 -0
- package/lib/components/style.test.d.ts +2 -0
- package/lib/components/style.test.d.ts.map +1 -0
- package/lib/components/style.test.js +135 -0
- package/lib/components/style.test.js.map +1 -0
- package/lib/components/text.d.ts +24 -0
- package/lib/components/text.d.ts.map +1 -0
- package/lib/components/text.js +65 -0
- package/lib/components/text.js.map +1 -0
- package/lib/drawing/boxes.d.ts +33 -0
- package/lib/drawing/boxes.d.ts.map +1 -0
- package/lib/drawing/boxes.js +76 -0
- package/lib/drawing/boxes.js.map +1 -0
- package/lib/index.d.ts +18 -11
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +21 -10
- package/lib/index.js.map +1 -1
- package/lib/index.test.d.ts +2 -0
- package/lib/index.test.d.ts.map +1 -0
- package/lib/index.test.js +80 -0
- package/lib/index.test.js.map +1 -0
- package/lib/layout/flex.d.ts +39 -0
- package/lib/layout/flex.d.ts.map +1 -0
- package/lib/layout/flex.js +300 -0
- package/lib/layout/flex.js.map +1 -0
- package/lib/layout/flex.test.d.ts +2 -0
- package/lib/layout/flex.test.d.ts.map +1 -0
- package/lib/layout/flex.test.js +206 -0
- package/lib/layout/flex.test.js.map +1 -0
- package/lib/layout/grid.d.ts +53 -0
- package/lib/layout/grid.d.ts.map +1 -0
- package/lib/layout/grid.js +421 -0
- package/lib/layout/grid.js.map +1 -0
- package/lib/layout/grid.test.d.ts +2 -0
- package/lib/layout/grid.test.d.ts.map +1 -0
- package/lib/layout/grid.test.js +139 -0
- package/lib/layout/grid.test.js.map +1 -0
- package/lib/native/ansi.d.ts +104 -0
- package/lib/native/ansi.d.ts.map +1 -0
- package/lib/native/ansi.js +120 -0
- package/lib/native/ansi.js.map +1 -0
- package/lib/native/ansi.test.d.ts +2 -0
- package/lib/native/ansi.test.d.ts.map +1 -0
- package/lib/native/ansi.test.js +57 -0
- package/lib/native/ansi.test.js.map +1 -0
- package/lib/native/buffer.d.ts +32 -0
- package/lib/native/buffer.d.ts.map +1 -0
- package/lib/native/buffer.js +49 -0
- package/lib/native/buffer.js.map +1 -0
- package/lib/native/buffer.test.d.ts +2 -0
- package/lib/native/buffer.test.d.ts.map +1 -0
- package/lib/native/buffer.test.js +64 -0
- package/lib/native/buffer.test.js.map +1 -0
- package/lib/native/diff.d.ts +20 -0
- package/lib/native/diff.d.ts.map +1 -0
- package/lib/native/diff.js +33 -0
- package/lib/native/diff.js.map +1 -0
- package/lib/native/diff.test.d.ts +2 -0
- package/lib/native/diff.test.d.ts.map +1 -0
- package/lib/native/diff.test.js +106 -0
- package/lib/native/diff.test.js.map +1 -0
- package/lib/native/region-old.d.ts +117 -0
- package/lib/native/region-old.d.ts.map +1 -0
- package/lib/native/region-old.js +539 -0
- package/lib/native/region-old.js.map +1 -0
- package/lib/native/region-renderer.d.ts +167 -0
- package/lib/native/region-renderer.d.ts.map +1 -0
- package/lib/native/region-renderer.js +1238 -0
- package/lib/native/region-renderer.js.map +1 -0
- package/lib/native/region-simple.d.ts +44 -0
- package/lib/native/region-simple.d.ts.map +1 -0
- package/lib/native/region-simple.js +290 -0
- package/lib/native/region-simple.js.map +1 -0
- package/lib/native/region.d.ts +53 -0
- package/lib/native/region.d.ts.map +1 -0
- package/lib/native/region.js +426 -0
- package/lib/native/region.js.map +1 -0
- package/lib/native/region.test.d.ts +2 -0
- package/lib/native/region.test.d.ts.map +1 -0
- package/lib/native/region.test.js +248 -0
- package/lib/native/region.test.js.map +1 -0
- package/lib/native/throttle.d.ts +29 -0
- package/lib/native/throttle.d.ts.map +1 -0
- package/lib/native/throttle.js +57 -0
- package/lib/native/throttle.js.map +1 -0
- package/lib/native/throttle.test.d.ts +2 -0
- package/lib/native/throttle.test.d.ts.map +1 -0
- package/lib/native/throttle.test.js +86 -0
- package/lib/native/throttle.test.js.map +1 -0
- package/lib/native.d.ts +5 -11
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +8 -64
- package/lib/native.js.map +1 -1
- package/lib/region.d.ts +48 -5
- package/lib/region.d.ts.map +1 -1
- package/lib/region.js +474 -36
- package/lib/region.js.map +1 -1
- package/lib/region.test.d.ts +2 -0
- package/lib/region.test.d.ts.map +1 -0
- package/lib/region.test.js +227 -0
- package/lib/region.test.js.map +1 -0
- package/lib/region.visual.test.d.ts +2 -0
- package/lib/region.visual.test.d.ts.map +1 -0
- package/lib/region.visual.test.js +55 -0
- package/lib/region.visual.test.js.map +1 -0
- package/lib/test-helpers/capturable-terminal.d.ts +61 -0
- package/lib/test-helpers/capturable-terminal.d.ts.map +1 -0
- package/lib/test-helpers/capturable-terminal.js +113 -0
- package/lib/test-helpers/capturable-terminal.js.map +1 -0
- package/lib/test-helpers/capturable-terminal.test.d.ts +2 -0
- package/lib/test-helpers/capturable-terminal.test.d.ts.map +1 -0
- package/lib/test-helpers/capturable-terminal.test.js +45 -0
- package/lib/test-helpers/capturable-terminal.test.js.map +1 -0
- package/lib/test-helpers/mock-region.d.ts +21 -0
- package/lib/test-helpers/mock-region.d.ts.map +1 -0
- package/lib/test-helpers/mock-region.js +37 -0
- package/lib/test-helpers/mock-region.js.map +1 -0
- package/lib/test-helpers/virtual-terminal-diff-reflow.test.d.ts +2 -0
- package/lib/test-helpers/virtual-terminal-diff-reflow.test.d.ts.map +1 -0
- package/lib/test-helpers/virtual-terminal-diff-reflow.test.js +256 -0
- package/lib/test-helpers/virtual-terminal-diff-reflow.test.js.map +1 -0
- package/lib/test-helpers/virtual-terminal-flex-resize.test.d.ts +2 -0
- package/lib/test-helpers/virtual-terminal-flex-resize.test.d.ts.map +1 -0
- package/lib/test-helpers/virtual-terminal-flex-resize.test.js +438 -0
- package/lib/test-helpers/virtual-terminal-flex-resize.test.js.map +1 -0
- package/lib/test-helpers/virtual-terminal.d.ts +176 -0
- package/lib/test-helpers/virtual-terminal.d.ts.map +1 -0
- package/lib/test-helpers/virtual-terminal.js +492 -0
- package/lib/test-helpers/virtual-terminal.js.map +1 -0
- package/lib/test-helpers/virtual-terminal.test.d.ts +2 -0
- package/lib/test-helpers/virtual-terminal.test.d.ts.map +1 -0
- package/lib/test-helpers/virtual-terminal.test.js +219 -0
- package/lib/test-helpers/virtual-terminal.test.js.map +1 -0
- package/lib/ts/components/spinner.test.d.ts +2 -0
- package/lib/ts/components/spinner.test.d.ts.map +1 -0
- package/lib/ts/components/spinner.test.js +168 -0
- package/lib/ts/components/spinner.test.js.map +1 -0
- package/lib/ts/utils/colors.test.d.ts +2 -0
- package/lib/ts/utils/colors.test.d.ts.map +1 -0
- package/lib/ts/utils/colors.test.js +87 -0
- package/lib/ts/utils/colors.test.js.map +1 -0
- package/lib/types.d.ts +8 -2
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js.map +1 -1
- package/lib/utils/colors-simple.d.ts +51 -0
- package/lib/utils/colors-simple.d.ts.map +1 -0
- package/lib/utils/colors-simple.js +59 -0
- package/lib/utils/colors-simple.js.map +1 -0
- package/lib/utils/colors.d.ts +1 -1
- package/lib/utils/colors.d.ts.map +1 -1
- package/lib/utils/colors.js.map +1 -1
- package/lib/utils/colors.test.d.ts +2 -0
- package/lib/utils/colors.test.d.ts.map +1 -0
- package/lib/utils/colors.test.js +87 -0
- package/lib/utils/colors.test.js.map +1 -0
- package/lib/utils/cursor-position.d.ts +22 -0
- package/lib/utils/cursor-position.d.ts.map +1 -0
- package/lib/utils/cursor-position.js +139 -0
- package/lib/utils/cursor-position.js.map +1 -0
- package/lib/utils/debug-log.d.ts +2 -0
- package/lib/utils/debug-log.d.ts.map +1 -0
- package/lib/utils/debug-log.js +36 -0
- package/lib/utils/debug-log.js.map +1 -0
- package/lib/utils/terminal.d.ts +27 -0
- package/lib/utils/terminal.d.ts.map +1 -0
- package/lib/utils/terminal.js +116 -0
- package/lib/utils/terminal.js.map +1 -0
- package/lib/utils/text.d.ts +21 -0
- package/lib/utils/text.d.ts.map +1 -0
- package/lib/utils/text.js +82 -0
- package/lib/utils/text.js.map +1 -0
- package/lib/utils/wait-for-spacebar.d.ts +10 -0
- package/lib/utils/wait-for-spacebar.d.ts.map +1 -0
- package/lib/utils/wait-for-spacebar.js +71 -0
- package/lib/utils/wait-for-spacebar.js.map +1 -0
- package/package.json +35 -28
- package/.cursor/plan.md +0 -952
- package/TESTING.md +0 -102
- package/build.zig +0 -100
- package/examples/basic-progress.ts +0 -21
- package/examples/multi-lane.ts +0 -29
- package/examples/spinner.ts +0 -20
- package/examples/test-basic.ts +0 -23
- package/src/ts/components/progress-bar.ts +0 -53
- package/src/ts/components/spinner.ts +0 -56
- package/src/ts/index.ts +0 -37
- package/src/ts/native.ts +0 -86
- package/src/ts/region.ts +0 -89
- package/src/ts/types/ffi-napi.d.ts +0 -11
- package/src/ts/types/ref-napi.d.ts +0 -5
- package/src/ts/types.ts +0 -53
- package/src/ts/utils/colors.ts +0 -72
- package/src/zig/ansi.zig +0 -21
- package/src/zig/buffer.zig +0 -37
- package/src/zig/diff.zig +0 -43
- package/src/zig/region.zig +0 -292
- package/src/zig/renderer.zig +0 -92
- package/src/zig/test_ansi.zig +0 -66
- package/src/zig/test_buffer.zig +0 -82
- package/src/zig/test_diff.zig +0 -220
- package/src/zig/test_integration.zig +0 -76
- package/src/zig/test_region.zig +0 -191
- package/src/zig/test_runner.zig +0 -27
- package/src/zig/test_throttle.zig +0 -59
- package/src/zig/throttle.zig +0 -38
- package/tsconfig.json +0 -21
|
@@ -0,0 +1,1238 @@
|
|
|
1
|
+
// Region management for terminal rendering - TypeScript implementation
|
|
2
|
+
// Optimized for Node.js stdout performance
|
|
3
|
+
import * as ansi from './ansi';
|
|
4
|
+
import { RenderBuffer } from './buffer';
|
|
5
|
+
import { Throttle } from './throttle';
|
|
6
|
+
import { queryCursorPosition } from '../utils/cursor-position';
|
|
7
|
+
import { getTerminalWidth, onResize } from '../utils/terminal';
|
|
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
|
+
*/
|
|
23
|
+
export class RegionRenderer {
|
|
24
|
+
width;
|
|
25
|
+
height;
|
|
26
|
+
pendingFrame = [];
|
|
27
|
+
previousFrame = [];
|
|
28
|
+
renderScheduled = false;
|
|
29
|
+
throttle;
|
|
30
|
+
renderBuffer;
|
|
31
|
+
stdout;
|
|
32
|
+
disableRendering;
|
|
33
|
+
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
|
+
onKeepAlive;
|
|
49
|
+
// Static tracking for exit handler (singleton pattern to prevent memory leak)
|
|
50
|
+
static exitHandlerSetup = false;
|
|
51
|
+
static activeRegions = new Set();
|
|
52
|
+
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
|
+
this.stdout = options.stdout ?? process.stdout;
|
|
64
|
+
this.permanentlyDisabled = options.disableRendering ?? false;
|
|
65
|
+
this.disableRendering = this.permanentlyDisabled;
|
|
66
|
+
this.onKeepAlive = options.onKeepAlive;
|
|
67
|
+
// Initialize frames with empty lines
|
|
68
|
+
this.pendingFrame = Array(this.height).fill('');
|
|
69
|
+
this.previousFrame = Array(this.height).fill('');
|
|
70
|
+
this.throttle = new Throttle(60); // Default 60 FPS
|
|
71
|
+
this.renderBuffer = new RenderBuffer(this.stdout);
|
|
72
|
+
// Reserve space for the region by printing newlines
|
|
73
|
+
// Start initialization async (but don't await - it will be awaited on first flush)
|
|
74
|
+
if (!this.permanentlyDisabled) {
|
|
75
|
+
this.initializationPromise = this.initializeRegion();
|
|
76
|
+
}
|
|
77
|
+
// Set up resize handling if width was not explicitly set (auto-resize enabled)
|
|
78
|
+
if (!this.widthExplicitlySet && !this.permanentlyDisabled) {
|
|
79
|
+
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);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Set up process exit handler to automatically clean up all regions
|
|
91
|
+
* Uses singleton pattern to prevent memory leak from multiple listeners
|
|
92
|
+
*/
|
|
93
|
+
static setupExitHandler() {
|
|
94
|
+
// Only set up once globally
|
|
95
|
+
if (RegionRenderer.exitHandlerSetup) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
RegionRenderer.exitHandlerSetup = true;
|
|
99
|
+
// Cleanup function that destroys all active regions
|
|
100
|
+
const cleanup = () => {
|
|
101
|
+
// Destroy all active regions
|
|
102
|
+
for (const region of RegionRenderer.activeRegions) {
|
|
103
|
+
if (region.isInitialized) {
|
|
104
|
+
region.destroy();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
RegionRenderer.activeRegions.clear();
|
|
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
|
+
});
|
|
122
|
+
}
|
|
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
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// Use the global onResize utility (for real process.stdout)
|
|
180
|
+
this.resizeCleanup = onResize((newWidth, newHeight) => {
|
|
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
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Initialize the region by reserving new lines at the bottom of the terminal.
|
|
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)
|
|
255
|
+
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
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
// Not a TTY - estimate
|
|
287
|
+
const terminalHeight = process.stdout.isTTY && process.stdout.rows ? process.stdout.rows : 24;
|
|
288
|
+
this.startRow = terminalHeight - this.height + 1;
|
|
289
|
+
this.logToFile(`[initializeRegion] Not a TTY, estimating startRow=${this.startRow} (timestamp: ${new Date().toISOString()})`);
|
|
290
|
+
}
|
|
291
|
+
this.isInitialized = true;
|
|
292
|
+
// CRITICAL: Position to startRow before printing newlines
|
|
293
|
+
// We queried the cursor position and set startRow = queriedRow + 2
|
|
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})`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Reserve space by printing newlines (this moves cursor down)
|
|
304
|
+
// Each newline reserves one line for the region
|
|
305
|
+
// The region starts where the cursor is BEFORE printing newlines (which is now at startRow)
|
|
306
|
+
// After printing newlines, we're at the line after the region
|
|
307
|
+
for (let i = 0; i < this.height; i++) {
|
|
308
|
+
this.stdout.write('\n');
|
|
309
|
+
}
|
|
310
|
+
// After printing newlines, cursor is at the start of the line after the region
|
|
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;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Query cursor position after resize and store it for use in rendering
|
|
325
|
+
* This helps us position correctly when the saved cursor position is invalid
|
|
326
|
+
*/
|
|
327
|
+
async queryCursorPositionAfterResize() {
|
|
328
|
+
if (this.disableRendering || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
329
|
+
return; // Can't query if not a TTY
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
// Query cursor position after a small delay to let resize settle
|
|
333
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
334
|
+
const pos = await queryCursorPosition(500);
|
|
335
|
+
this.queriedCursorPosition = pos;
|
|
336
|
+
this.logToFile(`[cursor query] After resize - queried cursor at row=${pos.row}, col=${pos.col}`);
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
this.logToFile(`[cursor query] Failed to query cursor position: ${err instanceof Error ? err.message : String(err)}`);
|
|
340
|
+
this.queriedCursorPosition = null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Poll cursor position multiple times after resize to understand if it's moving
|
|
345
|
+
* This helps debug cursor position inconsistencies on resize
|
|
346
|
+
*/
|
|
347
|
+
pollCursorPositionAfterResize() {
|
|
348
|
+
if (this.disableRendering || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
349
|
+
return; // Can't poll if not a TTY
|
|
350
|
+
}
|
|
351
|
+
let pollCount = 0;
|
|
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);
|
|
391
|
+
}
|
|
392
|
+
logToFile(message) {
|
|
393
|
+
logToFileUtil(message);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Strip ANSI escape codes from a string
|
|
397
|
+
*/
|
|
398
|
+
stripAnsi(str) {
|
|
399
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Get terminal height, with fallback
|
|
403
|
+
*/
|
|
404
|
+
getTerminalHeight() {
|
|
405
|
+
return this.stdout.isTTY && this.stdout.rows
|
|
406
|
+
? this.stdout.rows
|
|
407
|
+
: 24; // fallback
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Truncate content to maxWidth while preserving ANSI codes
|
|
411
|
+
* Only truncates if content is significantly longer (more than 2 chars) as a safety measure
|
|
412
|
+
*/
|
|
413
|
+
truncateContent(content, maxWidth) {
|
|
414
|
+
const plainContent = this.stripAnsi(content);
|
|
415
|
+
// CRITICAL: Never write exactly maxWidth characters - it puts cursor at last column
|
|
416
|
+
// Writing exactly maxWidth (e.g., 71) puts cursor at column maxWidth+1 (e.g., 72 = terminal width)
|
|
417
|
+
// This can cause cursor positioning issues. Always truncate to maxWidth - 1
|
|
418
|
+
const safeMaxWidth = maxWidth - 1;
|
|
419
|
+
if (plainContent.length <= safeMaxWidth) {
|
|
420
|
+
return content;
|
|
421
|
+
}
|
|
422
|
+
// Find where to cut while preserving ANSI codes
|
|
423
|
+
let visualPos = 0;
|
|
424
|
+
let charPos = 0;
|
|
425
|
+
while (charPos < content.length && visualPos < safeMaxWidth) {
|
|
426
|
+
if (content[charPos] === '\x1b') {
|
|
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
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return content.substring(0, charPos);
|
|
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));
|
|
474
|
+
}
|
|
475
|
+
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
476
|
+
this.renderBuffer.write(ansi.SAVE_CURSOR);
|
|
477
|
+
this.savedCursorPosition = true;
|
|
478
|
+
if (context) {
|
|
479
|
+
this.logToFile(`[renderNow] Saved cursor at top-left ${context}`);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
this.logToFile(`[renderNow] Saved cursor at top-left of region (linesToRender=${linesToRender})`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Check if content has changed between previous and pending frames
|
|
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('');
|
|
515
|
+
}
|
|
516
|
+
// CRITICAL: Only expand previousFrame if we're actually rendering
|
|
517
|
+
// previousFrame should represent what was actually rendered, not what we plan to render
|
|
518
|
+
// If disableRendering=true, we haven't rendered yet, so don't expand previousFrame
|
|
519
|
+
// This prevents false negatives in content-change detection
|
|
520
|
+
if (!this.disableRendering) {
|
|
521
|
+
while (this.previousFrame.length < newHeight) {
|
|
522
|
+
this.previousFrame.push('');
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// If we need more lines and region is initialized, reserve additional space
|
|
526
|
+
// CRITICAL: Only expand if height actually increased AND we haven't already expanded to this height
|
|
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
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Get a single line (1-based line numbers)
|
|
562
|
+
* Returns empty string if line doesn't exist
|
|
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] || '';
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Set a single line (1-based line numbers)
|
|
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('');
|
|
598
|
+
}
|
|
599
|
+
// Update the line
|
|
600
|
+
this.pendingFrame[lineIndex] = content;
|
|
601
|
+
// Schedule render
|
|
602
|
+
this.scheduleRender();
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Set entire content (multiple lines with \n separators)
|
|
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) {
|
|
619
|
+
this.pendingFrame.push('');
|
|
620
|
+
}
|
|
621
|
+
// If we shrunk (fewer lines than before), clear the extra lines in previous frame too
|
|
622
|
+
// This ensures the diff algorithm will detect them as needing to be cleared
|
|
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
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// Schedule render
|
|
629
|
+
this.scheduleRender();
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Schedule a render (respects throttle)
|
|
633
|
+
*/
|
|
634
|
+
scheduleRender() {
|
|
635
|
+
if (this.disableRendering) {
|
|
636
|
+
// CRITICAL: Don't copy pendingFrame to previousFrame when disableRendering=true
|
|
637
|
+
// previousFrame should represent what was actually rendered, not what we plan to render
|
|
638
|
+
// If we copy here, content-change detection will fail because previousFrame will match pendingFrame
|
|
639
|
+
// The copy will happen after actual rendering in renderNow() or copyPendingToPrevious()
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
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
|
+
void this.renderNow();
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
this.renderScheduled = true;
|
|
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];
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Render immediately (bypasses throttle)
|
|
660
|
+
* Uses relative cursor movements to update only within reserved lines
|
|
661
|
+
*/
|
|
662
|
+
async renderNow() {
|
|
663
|
+
if (this.disableRendering) {
|
|
664
|
+
this.copyPendingToPrevious();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
// CRITICAL: Prevent concurrent renders
|
|
668
|
+
// If we're already rendering, skip this render (the current render will show the latest state)
|
|
669
|
+
if (this.isRendering) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
this.isRendering = true;
|
|
673
|
+
try {
|
|
674
|
+
// CRITICAL: Ensure region is initialized before rendering
|
|
675
|
+
// Await initialization if it's in progress (this ensures cursor query completes and stdin is restored)
|
|
676
|
+
if (!this.isInitialized && this.initializationPromise) {
|
|
677
|
+
await this.initializationPromise;
|
|
678
|
+
}
|
|
679
|
+
else if (!this.isInitialized) {
|
|
680
|
+
// Shouldn't happen, but fallback
|
|
681
|
+
await this.initializeRegion();
|
|
682
|
+
}
|
|
683
|
+
// CRITICAL: Always disable auto-wrap before every render
|
|
684
|
+
// Some terminals reset this state, or other code might enable it
|
|
685
|
+
// We MUST write it directly to stdout (not to render buffer) so it takes effect immediately
|
|
686
|
+
// Node.js stdout.write() is synchronous and blocks until written, so it's effectively flushed
|
|
687
|
+
if (!this.disableRendering) {
|
|
688
|
+
// Write directly to stdout (bypasses render buffer) to ensure immediate effect
|
|
689
|
+
this.stdout.write(ansi.DISABLE_AUTO_WRAP);
|
|
690
|
+
this.autoWrapDisabled = true;
|
|
691
|
+
}
|
|
692
|
+
// CRITICAL: Check if content has actually changed before rendering
|
|
693
|
+
// This prevents unnecessary re-renders that cause duplicates
|
|
694
|
+
const { contentChanged, heightChanged } = this.hasContentChanged();
|
|
695
|
+
// Log detailed comparison to understand why content is changing
|
|
696
|
+
if (contentChanged && this.hasRendered) {
|
|
697
|
+
const firstDiffIndex = this.previousFrame.findIndex((line, i) => {
|
|
698
|
+
const prevLine = this.stripAnsi(line);
|
|
699
|
+
const pendingLine = this.stripAnsi(this.pendingFrame[i] ?? '');
|
|
700
|
+
return prevLine !== pendingLine;
|
|
701
|
+
});
|
|
702
|
+
const prevLine = firstDiffIndex >= 0 ? this.stripAnsi(this.previousFrame[firstDiffIndex]) : 'N/A';
|
|
703
|
+
const pendingLine = firstDiffIndex >= 0 ? this.stripAnsi(this.pendingFrame[firstDiffIndex] ?? '') : 'N/A';
|
|
704
|
+
this.logToFile(`[renderNow] contentChanged=true (first diff at index ${firstDiffIndex})`);
|
|
705
|
+
this.logToFile(`[renderNow] prev[${firstDiffIndex}]: "${prevLine.substring(0, 80)}..."`);
|
|
706
|
+
this.logToFile(`[renderNow] pending[${firstDiffIndex}]: "${pendingLine.substring(0, 80)}..."`);
|
|
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));
|
|
759
|
+
}
|
|
760
|
+
this.renderBuffer.write(ansi.MOVE_TO_START_OF_LINE);
|
|
761
|
+
this.logToFile(`[renderNow] After positioning, cursor should be at terminal row ${this.startRow !== null ? this.startRow + visibleStartLineIndex : 'unknown'} (start of visible region)`);
|
|
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));
|
|
773
|
+
}
|
|
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
|
+
}
|
|
787
|
+
// Render all lines
|
|
788
|
+
for (let i = 0; i < linesToRender; i++) {
|
|
789
|
+
const lineIndex = startLineIndex + i;
|
|
790
|
+
const content = this.pendingFrame[lineIndex] || '';
|
|
791
|
+
const isLastLine = (i === linesToRender - 1);
|
|
792
|
+
this.clearCurrentLine();
|
|
793
|
+
const contentToWrite = this.truncateContent(content, this.width);
|
|
794
|
+
const isFirstLine = lineIndex === 0;
|
|
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);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
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
|
+
this.renderBuffer.flush();
|
|
810
|
+
this.stdout.write(ansi.SAVE_CURSOR);
|
|
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
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
// Subsequent renders - unified positioning and rendering logic
|
|
852
|
+
// Calculate which lines to render first (needed for positioning)
|
|
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)`);
|
|
1086
|
+
}
|
|
1087
|
+
// Handle deletions (lines that were in previousFrame but not in pendingFrame)
|
|
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
|
|
1096
|
+
this.copyPendingToPrevious();
|
|
1097
|
+
// Update last rendered height for next render
|
|
1098
|
+
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
|
+
}
|
|
1108
|
+
finally {
|
|
1109
|
+
this.isRendering = false;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Force immediate render of pending updates (bypasses throttle)
|
|
1114
|
+
* Returns a promise that resolves when rendering is complete
|
|
1115
|
+
*/
|
|
1116
|
+
async flush() {
|
|
1117
|
+
// CRITICAL: Await initialization before rendering
|
|
1118
|
+
// This ensures cursor query completes and stdin is restored before any rendering
|
|
1119
|
+
if (!this.isInitialized && this.initializationPromise) {
|
|
1120
|
+
await this.initializationPromise;
|
|
1121
|
+
}
|
|
1122
|
+
await this.renderNow();
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Set throttle FPS
|
|
1126
|
+
*/
|
|
1127
|
+
setThrottleFps(fps) {
|
|
1128
|
+
this.throttle.setFps(fps);
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Clear a single line (1-based)
|
|
1132
|
+
*/
|
|
1133
|
+
clearLine(lineNumber) {
|
|
1134
|
+
if (lineNumber < 1) {
|
|
1135
|
+
throw new Error('Line numbers start at 1');
|
|
1136
|
+
}
|
|
1137
|
+
this.setLine(lineNumber, '');
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Clear entire region
|
|
1141
|
+
*/
|
|
1142
|
+
clear() {
|
|
1143
|
+
// CRITICAL: Disable rendering during clear to prevent multiple renders
|
|
1144
|
+
// We'll render once at the end if needed
|
|
1145
|
+
const wasRenderingDisabled = this.disableRendering;
|
|
1146
|
+
this.disableRendering = true;
|
|
1147
|
+
// Clear all lines in pendingFrame (which may be larger than height if region expanded)
|
|
1148
|
+
const maxLines = Math.max(this.height, this.pendingFrame.length);
|
|
1149
|
+
for (let i = 1; i <= maxLines; i++) {
|
|
1150
|
+
this.setLine(i, '');
|
|
1151
|
+
}
|
|
1152
|
+
// Re-enable rendering
|
|
1153
|
+
this.disableRendering = wasRenderingDisabled;
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Destroy the region (cleanup)
|
|
1157
|
+
* Automatically deletes any blank lines from the terminal, but preserves content
|
|
1158
|
+
*
|
|
1159
|
+
* @param clearFirst - If true, clears the region before destroying (default: false)
|
|
1160
|
+
*
|
|
1161
|
+
* Note: This is automatically called on process exit, but you can also call it explicitly
|
|
1162
|
+
* to clean up resources earlier (e.g., before continuing with other terminal output)
|
|
1163
|
+
*/
|
|
1164
|
+
async destroy(clearFirst = false) {
|
|
1165
|
+
// CRITICAL: Mark as destroyed FIRST to prevent resize handler from interfering
|
|
1166
|
+
// This ensures resize handler won't re-disable auto-wrap after we re-enable it
|
|
1167
|
+
const wasInitialized = this.isInitialized;
|
|
1168
|
+
this.isInitialized = false;
|
|
1169
|
+
// Remove from active regions set (prevent memory leak)
|
|
1170
|
+
RegionRenderer.activeRegions.delete(this);
|
|
1171
|
+
// Re-enable terminal auto-wrap if we disabled it
|
|
1172
|
+
// Write directly to stdout to ensure it takes effect immediately
|
|
1173
|
+
// CRITICAL: Always re-enable auto-wrap on destroy to prevent zsh/terminal issues
|
|
1174
|
+
// When auto-wrap is disabled and terminal resizes, zsh may insert lines
|
|
1175
|
+
if (!this.disableRendering && this.autoWrapDisabled) {
|
|
1176
|
+
this.stdout.write(ansi.ENABLE_AUTO_WRAP);
|
|
1177
|
+
this.autoWrapDisabled = false;
|
|
1178
|
+
}
|
|
1179
|
+
// Prevent double-destruction
|
|
1180
|
+
if (!wasInitialized && this.pendingFrame.length === 0) {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
// Clean up resize handler if it exists
|
|
1184
|
+
if (this.resizeCleanup) {
|
|
1185
|
+
this.resizeCleanup();
|
|
1186
|
+
this.resizeCleanup = undefined;
|
|
1187
|
+
}
|
|
1188
|
+
// Clear if requested
|
|
1189
|
+
if (clearFirst) {
|
|
1190
|
+
this.clear();
|
|
1191
|
+
// Render the clear immediately so previousFrame is updated
|
|
1192
|
+
await this.renderNow();
|
|
1193
|
+
}
|
|
1194
|
+
if (!this.disableRendering && wasInitialized) {
|
|
1195
|
+
// Check if all lines are blank
|
|
1196
|
+
// After clearFirst + renderNow(), previousFrame will have empty lines
|
|
1197
|
+
// Otherwise, check previousFrame to see if content exists
|
|
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();
|
|
1215
|
+
}
|
|
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
|
+
}
|
|
1223
|
+
// Clear buffers (internal state only, doesn't affect terminal)
|
|
1224
|
+
this.pendingFrame = [];
|
|
1225
|
+
this.previousFrame = [];
|
|
1226
|
+
this.renderBuffer.clear();
|
|
1227
|
+
// Mark as destroyed to prevent double-cleanup
|
|
1228
|
+
this.isInitialized = false;
|
|
1229
|
+
}
|
|
1230
|
+
// Getters for width and height
|
|
1231
|
+
getWidth() {
|
|
1232
|
+
return this.width;
|
|
1233
|
+
}
|
|
1234
|
+
getHeight() {
|
|
1235
|
+
return this.height;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
//# sourceMappingURL=region-renderer.js.map
|