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.
Files changed (110) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +34 -8
  3. package/lib/component.d.ts +34 -0
  4. package/lib/component.d.ts.map +1 -0
  5. package/lib/component.js +42 -0
  6. package/lib/component.js.map +1 -0
  7. package/lib/components/code-debug.d.ts +35 -0
  8. package/lib/components/code-debug.d.ts.map +1 -0
  9. package/lib/components/code-debug.js +294 -0
  10. package/lib/components/code-debug.js.map +1 -0
  11. package/lib/components/fill.d.ts +15 -0
  12. package/lib/components/fill.d.ts.map +1 -0
  13. package/lib/components/fill.js +37 -0
  14. package/lib/components/fill.js.map +1 -0
  15. package/lib/components/index.d.ts +2 -2
  16. package/lib/components/index.d.ts.map +1 -1
  17. package/lib/components/index.js +2 -2
  18. package/lib/components/index.js.map +1 -1
  19. package/lib/components/progress-bar-grid.d.ts +1 -1
  20. package/lib/components/progress-bar-grid.d.ts.map +1 -1
  21. package/lib/components/progress-bar-grid.js +6 -6
  22. package/lib/components/progress-bar-grid.js.map +1 -1
  23. package/lib/components/prompt.d.ts +4 -5
  24. package/lib/components/prompt.d.ts.map +1 -1
  25. package/lib/components/prompt.js +17 -69
  26. package/lib/components/prompt.js.map +1 -1
  27. package/lib/components/section.d.ts +33 -0
  28. package/lib/components/section.d.ts.map +1 -0
  29. package/lib/components/section.js +178 -0
  30. package/lib/components/section.js.map +1 -0
  31. package/lib/components/segments.d.ts +26 -0
  32. package/lib/components/segments.d.ts.map +1 -0
  33. package/lib/components/segments.js +105 -0
  34. package/lib/components/segments.js.map +1 -0
  35. package/lib/components/spinner.d.ts +18 -16
  36. package/lib/components/spinner.d.ts.map +1 -1
  37. package/lib/components/spinner.js +63 -47
  38. package/lib/components/spinner.js.map +1 -1
  39. package/lib/components/style.test.js +11 -11
  40. package/lib/components/style.test.js.map +1 -1
  41. package/lib/components/styled.d.ts +17 -0
  42. package/lib/components/styled.d.ts.map +1 -0
  43. package/lib/components/styled.js +107 -0
  44. package/lib/components/styled.js.map +1 -0
  45. package/lib/components/styled.test.d.ts +2 -0
  46. package/lib/components/styled.test.d.ts.map +1 -0
  47. package/lib/components/styled.test.js +135 -0
  48. package/lib/components/styled.test.js.map +1 -0
  49. package/lib/index.d.ts +17 -13
  50. package/lib/index.d.ts.map +1 -1
  51. package/lib/index.js +13 -13
  52. package/lib/index.js.map +1 -1
  53. package/lib/index.test.js +17 -11
  54. package/lib/index.test.js.map +1 -1
  55. package/lib/layout/grid.d.ts +31 -35
  56. package/lib/layout/grid.d.ts.map +1 -1
  57. package/lib/layout/grid.js +437 -216
  58. package/lib/layout/grid.js.map +1 -1
  59. package/lib/layout/grid.test.js +332 -36
  60. package/lib/layout/grid.test.js.map +1 -1
  61. package/lib/native/ansi.d.ts +9 -0
  62. package/lib/native/ansi.d.ts.map +1 -1
  63. package/lib/native/ansi.js +9 -0
  64. package/lib/native/ansi.js.map +1 -1
  65. package/lib/native/diff.d.ts +5 -1
  66. package/lib/native/diff.d.ts.map +1 -1
  67. package/lib/native/diff.js +25 -7
  68. package/lib/native/diff.js.map +1 -1
  69. package/lib/native/region-renderer-debug.test.d.ts +2 -0
  70. package/lib/native/region-renderer-debug.test.d.ts.map +1 -0
  71. package/lib/native/region-renderer-debug.test.js +45 -0
  72. package/lib/native/region-renderer-debug.test.js.map +1 -0
  73. package/lib/native/region-renderer.d.ts +57 -148
  74. package/lib/native/region-renderer.d.ts.map +1 -1
  75. package/lib/native/region-renderer.js +455 -1124
  76. package/lib/native/region-renderer.js.map +1 -1
  77. package/lib/native/region.test.js +2 -20
  78. package/lib/native/region.test.js.map +1 -1
  79. package/lib/region-resize.test.d.ts +2 -0
  80. package/lib/region-resize.test.d.ts.map +1 -0
  81. package/lib/region-resize.test.js +124 -0
  82. package/lib/region-resize.test.js.map +1 -0
  83. package/lib/region.d.ts +97 -9
  84. package/lib/region.d.ts.map +1 -1
  85. package/lib/region.js +591 -185
  86. package/lib/region.js.map +1 -1
  87. package/lib/region.test.js +3 -3
  88. package/lib/region.test.js.map +1 -1
  89. package/lib/types.d.ts +9 -0
  90. package/lib/types.d.ts.map +1 -1
  91. package/lib/utils/file-link.d.ts +16 -0
  92. package/lib/utils/file-link.d.ts.map +1 -0
  93. package/lib/utils/file-link.js +23 -0
  94. package/lib/utils/file-link.js.map +1 -0
  95. package/lib/utils/prompt.d.ts +15 -0
  96. package/lib/utils/prompt.d.ts.map +1 -0
  97. package/lib/utils/prompt.js +128 -0
  98. package/lib/utils/prompt.js.map +1 -0
  99. package/lib/utils/terminal-theme.d.ts +36 -0
  100. package/lib/utils/terminal-theme.d.ts.map +1 -0
  101. package/lib/utils/terminal-theme.js +61 -0
  102. package/lib/utils/terminal-theme.js.map +1 -0
  103. package/lib/utils/text.d.ts +53 -3
  104. package/lib/utils/text.d.ts.map +1 -1
  105. package/lib/utils/text.js +194 -36
  106. package/lib/utils/text.js.map +1 -1
  107. package/lib/utils/wait-for-spacebar.d.ts.map +1 -1
  108. package/lib/utils/wait-for-spacebar.js +9 -6
  109. package/lib/utils/wait-for-spacebar.js.map +1 -1
  110. package/package.json +17 -13
@@ -1,1238 +1,569 @@
1
- // Region management for terminal rendering - TypeScript implementation
2
- // Optimized for Node.js stdout performance
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 { 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
- */
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
- renderScheduled = false;
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
- // Static tracking for exit handler (singleton pattern to prevent memory leak)
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
- // Initialize frames with empty lines
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.throttle = new Throttle(60); // Default 60 FPS
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
- // Reserve space for the region by printing newlines
73
- // Start initialization async (but don't await - it will be awaited on first flush)
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.initializationPromise = this.initializeRegion();
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
- * 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;
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
- 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
- }
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
- 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
- });
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
- 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
- });
89
+ this.pendingFrame[index] = content;
90
+ if (lineNumber > this.height) {
91
+ this.height = lineNumber;
238
92
  }
93
+ this.scheduleRender();
239
94
  }
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)
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
- 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})`);
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
- // 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;
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
- * 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
119
+ getLine(lineNumber) {
120
+ if (lineNumber < 1) {
121
+ throw new Error('Line numbers start at 1');
330
122
  }
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}`);
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
- catch (err) {
339
- this.logToFile(`[cursor query] Failed to query cursor position: ${err instanceof Error ? err.message : String(err)}`);
340
- this.queriedCursorPosition = null;
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
- * 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
136
+ clear() {
137
+ for (let i = 0; i < this.pendingFrame.length; i++) {
138
+ this.pendingFrame[i] = '';
350
139
  }
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);
140
+ this.scheduleRender();
391
141
  }
392
- logToFile(message) {
393
- logToFileUtil(message);
142
+ async flush() {
143
+ await this.renderNow();
394
144
  }
395
- /**
396
- * Strip ANSI escape codes from a string
397
- */
398
- stripAnsi(str) {
399
- return str.replace(/\x1b\[[0-9;]*m/g, '');
145
+ expandTo(newHeight) {
146
+ if (newHeight <= this.height) {
147
+ return;
148
+ }
149
+ this.ensureFrameSize(newHeight);
150
+ this.height = newHeight;
400
151
  }
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
152
+ setHeight(height) {
153
+ this.height = height;
408
154
  }
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;
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
- // 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
- }
168
+ this.destroyed = true;
169
+ RegionRenderer.activeRegions.delete(this);
170
+ if (this.renderTimer) {
171
+ clearTimeout(this.renderTimer);
172
+ this.renderTimer = null;
449
173
  }
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));
174
+ if (this.resizeCleanup) {
175
+ this.resizeCleanup();
176
+ this.resizeCleanup = undefined;
474
177
  }
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}`);
178
+ if (this.permanentlyDisabled) {
179
+ return;
480
180
  }
481
- else {
482
- this.logToFile(`[renderNow] Saved cursor at top-left of region (linesToRender=${linesToRender})`);
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
- * 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('');
189
+ logToFile(message) {
190
+ if (!this.debugLogPath) {
191
+ return;
515
192
  }
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('');
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
- // 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
- }
201
+ catch {
202
+ // ignore logging failures
558
203
  }
559
204
  }
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] || '';
205
+ static registerRegion(region) {
206
+ RegionRenderer.activeRegions.add(region);
207
+ RegionRenderer.setupExitHandler();
573
208
  }
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('');
209
+ static setupExitHandler() {
210
+ if (RegionRenderer.exitHandlerSetup) {
211
+ return;
598
212
  }
599
- // Update the line
600
- this.pendingFrame[lineIndex] = content;
601
- // Schedule render
602
- this.scheduleRender();
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
- * 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) {
224
+ ensureFrameSize(size) {
225
+ while (this.pendingFrame.length < size) {
619
226
  this.pendingFrame.push('');
620
227
  }
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
- }
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
- // 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()
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
- 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];
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
- // 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));
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
- 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));
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
- // 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);
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.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
313
+ this.hideCursor();
849
314
  }
850
315
  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)`);
316
+ // Normal diff-based rendering (for incremental updates)
317
+ this.applyDiff(frame);
1086
318
  }
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
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
- * 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;
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
- await this.renderNow();
368
+ this.stdout.write(ansi.HIDE_CURSOR);
369
+ this.cursorVisible = false;
1123
370
  }
1124
- /**
1125
- * Set throttle FPS
1126
- */
1127
- setThrottleFps(fps) {
1128
- this.throttle.setFps(fps);
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
- * Clear a single line (1-based)
1132
- */
1133
- clearLine(lineNumber) {
1134
- if (lineNumber < 1) {
1135
- throw new Error('Line numbers start at 1');
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
- this.setLine(lineNumber, '');
402
+ return frame;
1138
403
  }
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, '');
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
- * 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) {
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
- // Prevent double-destruction
1180
- if (!wasInitialized && this.pendingFrame.length === 0) {
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
- // Clean up resize handler if it exists
1184
- if (this.resizeCleanup) {
1185
- this.resizeCleanup();
1186
- this.resizeCleanup = undefined;
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
- // Clear if requested
1189
- if (clearFirst) {
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 (!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();
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
- // 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;
551
+ return 80;
1229
552
  }
1230
- // Getters for width and height
1231
- getWidth() {
1232
- return this.width;
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
- getHeight() {
1235
- return this.height;
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