Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
searchAddon: SearchAddon;
wrapper: HTMLDivElement;
linkManager: TerminalLinkManager;
clearTextureAtlas: () => void;
cleanup: () => void;
} {
const {
Expand Down Expand Up @@ -216,6 +217,11 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
searchAddon,
wrapper,
linkManager,
clearTextureAtlas: () => {
try {
webglAddon?.clearTextureAtlas();
} catch {}
},
cleanup: () => {
disposed = true;
cancelAnimationFrame(rafId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Reproduction tests for issue #3504:
* "macOS terminal rendering can corrupt and ghost into chat until fullscreen toggle"
*
* Root cause: when a cached terminal is reattached to a DOM container (tab
* switch, pane focus), `attachToContainer` calls `xterm.refresh()` but never
* clears the WebGL texture atlas. Stale glyph textures from the previous
* rendering context persist, causing garbled text and background terminal
* content ghosting into the chat view. The same corruption can occur after
* a resize event changes the terminal dimensions.
*
* The xterm WebGL addon exposes `clearTextureAtlas()` for exactly this
* purpose (see xtermjs/xterm.js#3303). The fix adds a `clearTextureAtlas`
* callback to the CachedTerminal and calls it in `attachToContainer`
* before `refresh()`, and in the ResizeObserver when dimensions change.
*
* These tests model the core attach/resize logic from v1-terminal-cache.ts
* to verify the atlas is cleared at the right times without requiring a
* real browser/xterm environment.
*/
import { describe, expect, it } from "bun:test";

// ---------------------------------------------------------------------------
// Minimal model of v1-terminal-cache's attach and resize logic.
// Mirrors the exact sequence in attachToContainer so tests accurately
// demonstrate the production behaviour.
// ---------------------------------------------------------------------------

interface MockEntry {
lastCols: number;
lastRows: number;
clearTextureAtlasCalls: number;
refreshCalls: number;
clearTextureAtlas: () => void;
refresh: () => void;
fit: () => void;
/** Simulated container dimensions — set before attach/resize. */
nextCols: number;
nextRows: number;
}

function makeMockEntry(initialCols = 80, initialRows = 24): MockEntry {
const entry: MockEntry = {
lastCols: initialCols,
lastRows: initialRows,
clearTextureAtlasCalls: 0,
refreshCalls: 0,
nextCols: initialCols,
nextRows: initialRows,
clearTextureAtlas: () => {
entry.clearTextureAtlasCalls++;
},
refresh: () => {
entry.refreshCalls++;
},
fit: () => {
entry.lastCols = entry.nextCols;
entry.lastRows = entry.nextRows;
},
};
return entry;
}

/**
* Mirrors the logic in v1-terminal-cache.ts `attachToContainer`.
* With the fix, clearTextureAtlas is called before refresh.
*/
function attachToContainer(entry: MockEntry): void {
// fit if container has dimensions
entry.fit();

// Clear stale WebGL texture atlas before repaint (the fix)
entry.clearTextureAtlas();

// Refresh to repaint
entry.refresh();
}

/**
* Mirrors the ResizeObserver callback in attachToContainer.
* With the fix, clearTextureAtlas is called when dimensions change.
*/
function simulateResize(
entry: MockEntry,
newCols: number,
newRows: number,
): { resized: boolean } {
const prevCols = entry.lastCols;
const prevRows = entry.lastRows;
entry.nextCols = newCols;
entry.nextRows = newRows;
entry.fit();

if (entry.lastCols !== prevCols || entry.lastRows !== prevRows) {
entry.clearTextureAtlas();
return { resized: true };
}
return { resized: false };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("v1-terminal-cache attachToContainer — issue #3504", () => {
it("clears the WebGL texture atlas when reattaching a cached terminal", () => {
const entry = makeMockEntry();

// Simulate detach (tab switch away) then reattach (tab switch back)
attachToContainer(entry);

expect(entry.clearTextureAtlasCalls).toBe(1);
expect(entry.refreshCalls).toBe(1);
});

it("clears the atlas before refresh so the repaint uses fresh glyphs", () => {
const callOrder: string[] = [];
const entry = makeMockEntry();

// Override to track call order
entry.clearTextureAtlas = () => {
entry.clearTextureAtlasCalls++;
callOrder.push("clearTextureAtlas");
};
entry.refresh = () => {
entry.refreshCalls++;
callOrder.push("refresh");
};

attachToContainer(entry);

expect(callOrder).toEqual(["clearTextureAtlas", "refresh"]);
});

it("clears the atlas on resize when dimensions change", () => {
const entry = makeMockEntry(80, 24);

const result = simulateResize(entry, 120, 36);

expect(result.resized).toBe(true);
expect(entry.clearTextureAtlasCalls).toBe(1);
});

it("does NOT clear the atlas on resize when dimensions are unchanged", () => {
const entry = makeMockEntry(80, 24);

const result = simulateResize(entry, 80, 24);

expect(result.resized).toBe(false);
expect(entry.clearTextureAtlasCalls).toBe(0);
});

it("handles multiple reattach cycles accumulating atlas clears", () => {
const entry = makeMockEntry();

// Simulate 3 tab switches (detach + reattach each time)
attachToContainer(entry);
attachToContainer(entry);
attachToContainer(entry);

expect(entry.clearTextureAtlasCalls).toBe(3);
expect(entry.refreshCalls).toBe(3);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface CachedTerminal {
wrapper: HTMLDivElement;
/** Disposes renderer RAF, query suppression, GPU renderer, etc. */
cleanupCreation: () => void;
/** Clears the WebGL texture atlas to avoid stale glyph corruption on reattach/resize. */
clearTextureAtlas: () => void;
/** Last known dimensions — used to skip no-op resize events. */
lastCols: number;
lastRows: number;
Expand Down Expand Up @@ -75,7 +77,7 @@ export function getOrCreate(
console.log(`[v1-terminal-cache] Creating new terminal: ${paneId}`);
}

const { xterm, fitAddon, searchAddon, wrapper, cleanup } =
const { xterm, fitAddon, searchAddon, wrapper, clearTextureAtlas, cleanup } =
createTerminalInWrapper(options);

const entry: CachedTerminal = {
Expand All @@ -84,6 +86,7 @@ export function getOrCreate(
searchAddon,
wrapper,
cleanupCreation: cleanup,
clearTextureAtlas,
subscription: null,
streamReady: false,
pendingStreamEvents: [],
Expand Down Expand Up @@ -117,6 +120,12 @@ export function attachToContainer(
entry.lastRows = entry.xterm.rows;
}

// Clear the WebGL texture atlas to discard stale glyph textures that
// accumulate while the terminal is detached (macOS compositor corruption,
// see xtermjs/xterm.js#3303). Must happen before refresh so the repaint
// rebuilds glyphs from scratch.
entry.clearTextureAtlas();

// Renderer may have skipped frames while the wrapper was detached.
entry.xterm.refresh(0, Math.max(0, entry.xterm.rows - 1));

Expand All @@ -130,6 +139,7 @@ export function attachToContainer(
entry.lastCols = entry.xterm.cols;
entry.lastRows = entry.xterm.rows;
if (entry.lastCols !== prevCols || entry.lastRows !== prevRows) {
entry.clearTextureAtlas();
onResize?.();
}
});
Expand Down