Skip to content

Commit eac5cdd

Browse files
committed
fix(terminal): prevent ghost lines when resizing terminal panel
Clear the prompt region before xterm.js reflow to prevent ghost lines caused by reflowCursorLine:false (xterm default) which excludes the cursor row from reflow. When widening, wrapped prompt lines cannot merge back, leaving stale fragments visible. The fix walks up through isWrapped lines to find the prompt start, erases that region, then calls fitAddon.fit() inside the write() callback to ensure the erase is applied before reflow. Readline's SIGWINCH redraw then writes a clean prompt at the new width. Also removes the ResizeObserver (redundant with WorkspaceManager events), removes the PTY resize suppression mechanism, and increases the resize debounce to 300ms to avoid intermediate SIGWINCH during drag-resizing.
1 parent e26aed9 commit eac5cdd

1 file changed

Lines changed: 53 additions & 53 deletions

File tree

src/extensionsIntegrated/Terminal/TerminalInstance.js

Lines changed: 53 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,6 @@ define(function (require, exports, module) {
104104
this.searchAddon = null;
105105
this.$container = null;
106106
this._resizeTimeout = null;
107-
this._resizeObserver = null;
108-
this._lastCols = 0;
109-
this._lastRows = 0;
110-
this._suppressPtyResize = false;
111107
this._disposed = false;
112108

113109
// Bound event handlers for cleanup
@@ -157,12 +153,6 @@ define(function (require, exports, module) {
157153
// Fit to container
158154
this._fit();
159155

160-
// Use ResizeObserver for reliable resize detection
161-
this._resizeObserver = new ResizeObserver(() => {
162-
this.handleResize();
163-
});
164-
this._resizeObserver.observe(this.$container[0]);
165-
166156
// Set up custom key handler to intercept editor shortcuts
167157
this.terminal.attachCustomKeyEventHandler(this._customKeyHandler.bind(this));
168158

@@ -175,9 +165,9 @@ define(function (require, exports, module) {
175165
}
176166
});
177167

178-
// Wire resize: terminal -> PTY (suppressed during _fit to control timing)
168+
// Wire resize: terminal -> PTY
179169
this.terminal.onResize(({cols, rows}) => {
180-
if (this.isAlive && !this._suppressPtyResize) {
170+
if (this.isAlive) {
181171
this.nodeConnector.execPeer("resizeTerminal", {id: this.id, cols, rows}).catch((err) => {
182172
console.error("Terminal: resize error:", err);
183173
});
@@ -282,56 +272,70 @@ define(function (require, exports, module) {
282272

283273
/**
284274
* Fit the terminal to its container.
285-
* Suppresses the automatic PTY resize during fit() and clears the
286-
* prompt area to avoid garbled output caused by readline redrawing
287-
* the prompt on top of xterm's reflowed buffer.
288-
* Historical output above the prompt is preserved.
275+
*
276+
* Before reflowing, the prompt area is cleared so that stale wrapped
277+
* text does not survive the reflow as a "ghost" line. xterm.js
278+
* defaults to reflowCursorLine: false, meaning the cursor row is
279+
* excluded from reflow. When the terminal widens, a multi-line
280+
* wrapped prompt cannot merge back into one line; the first part
281+
* remains as a visible ghost. By erasing the prompt region first
282+
* (walking up through isWrapped lines), the reflow has nothing
283+
* stale to preserve, and readline's SIGWINCH redraw writes a
284+
* clean prompt at the new width.
289285
*/
290286
TerminalInstance.prototype._fit = function () {
291-
if (this.fitAddon && this.$container && this.$container.is(":visible")) {
292-
try {
293-
const dims = this.fitAddon.proposeDimensions();
294-
if (!dims || (dims.cols === this._lastCols && dims.rows === this._lastRows)) {
295-
return;
296-
}
287+
if (!this.fitAddon || !this.$container || !this.$container.is(":visible")) {
288+
return;
289+
}
297290

298-
this._lastCols = dims.cols;
299-
this._lastRows = dims.rows;
300-
301-
// Suppress automatic PTY resize from onResize handler
302-
this._suppressPtyResize = true;
303-
this.fitAddon.fit();
304-
this._suppressPtyResize = false;
305-
306-
if (this.isAlive) {
307-
// After reflow, clear only the prompt line and below to
308-
// remove garbled content from the reflow/SIGWINCH conflict.
309-
// Use cursor position after reflow — the cursor sits at the
310-
// end of the (possibly garbled) prompt. Clearing from the
311-
// start of the cursor row preserves all output above.
312-
const cursorY = this.terminal.buffer.active.cursorY;
313-
this.terminal.write("\x1b[" + (cursorY + 1) + ";1H\x1b[J");
314-
315-
this.nodeConnector.execPeer("resizeTerminal", {
316-
id: this.id, cols: dims.cols, rows: dims.rows
317-
}).catch((err) => {
318-
console.error("Terminal: resize error:", err);
319-
});
291+
try {
292+
// Clear the prompt region before reflow to prevent ghost lines.
293+
// xterm.js write() is asynchronous, so we must wait for the
294+
// clear to be processed before calling fit().
295+
if (this.terminal && this.isAlive) {
296+
const buf = this.terminal.buffer.active;
297+
let promptStart = buf.cursorY;
298+
299+
// Walk upward through wrapped lines to find prompt start
300+
while (promptStart > 0) {
301+
const line = buf.getLine(buf.baseY + promptStart);
302+
if (!line || !line.isWrapped) {
303+
break;
304+
}
305+
promptStart--;
320306
}
321-
} catch (e) {
322-
// Container might not be visible yet
307+
308+
// Erase from prompt start to end of screen, then fit
309+
// once the erase has been applied to the buffer.
310+
this.terminal.write(
311+
"\x1b[" + (promptStart + 1) + ";1H\x1b[J",
312+
() => {
313+
try {
314+
this.fitAddon.fit();
315+
} catch (e) {
316+
// Container might not be visible yet
317+
}
318+
}
319+
);
320+
return;
323321
}
322+
323+
this.fitAddon.fit();
324+
} catch (e) {
325+
// Container might not be visible yet
324326
}
325327
};
326328

327329
/**
328-
* Handle container resize - debounced
330+
* Handle container resize — debounced so that only the final size
331+
* triggers a reflow + PTY resize. This avoids garbled prompts from
332+
* intermediate SIGWINCH signals during continuous drag-resizing.
329333
*/
330334
TerminalInstance.prototype.handleResize = function () {
331335
clearTimeout(this._resizeTimeout);
332336
this._resizeTimeout = setTimeout(() => {
333337
this._fit();
334-
}, 150);
338+
}, 300);
335339
};
336340

337341
/**
@@ -402,12 +406,8 @@ define(function (require, exports, module) {
402406
this.isAlive = false;
403407
}
404408

405-
// Dispose resize observer and xterm
409+
// Dispose xterm
406410
clearTimeout(this._resizeTimeout);
407-
if (this._resizeObserver) {
408-
this._resizeObserver.disconnect();
409-
this._resizeObserver = null;
410-
}
411411
if (this.terminal) {
412412
this.terminal.dispose();
413413
this.terminal = null;

0 commit comments

Comments
 (0)