diff --git a/src/modules/terminal/lib/rendererPool.ts b/src/modules/terminal/lib/rendererPool.ts index 81c7d098a..433122a7f 100644 --- a/src/modules/terminal/lib/rendererPool.ts +++ b/src/modules/terminal/lib/rendererPool.ts @@ -18,6 +18,7 @@ import { terminalLineNavigationSequence, terminalWordNavigationSequence, } from "./keymap"; +import { pendingPtyResize } from "./rendererResize"; export const POOL_MAX_SIZE = 5; const FIT_DEBOUNCE_MS = 8; @@ -574,6 +575,21 @@ function rewireSlot(slot: Slot, p: AcquireParams): void { p.onSearchReady(slot.searchAddon); } +// Push any grid/PTY size delta to the PTY now, keeping slot.lastCols/lastRows +// as the source of truth for "what the PTY was last told". Called both when the +// debounce timer fires and on every teardown path, so an interrupted debounce +// can never leave the PTY winsize behind the rendered grid (tmux pane-bleed). +function commitPendingResize(slot: Slot, leafId: number): void { + const pending = pendingPtyResize( + { cols: slot.term.cols, rows: slot.term.rows }, + { cols: slot.lastCols, rows: slot.lastRows }, + ); + if (!pending) return; + slot.lastCols = pending.cols; + slot.lastRows = pending.rows; + adapter?.resolveLeaf(leafId)?.resizePty(pending.cols, pending.rows); +} + function setupResizeObserver(slot: Slot, p: AcquireParams): void { slot.observer?.disconnect(); if (slot.fitTimer) clearTimeout(slot.fitTimer); @@ -585,11 +601,7 @@ function setupResizeObserver(slot: Slot, p: AcquireParams): void { const flushPty = () => { slot.ptyTimer = null; if (slot.currentLeafId !== p.leafId) return; - if (slot.term.cols === slot.lastCols && slot.term.rows === slot.lastRows) - return; - slot.lastCols = slot.term.cols; - slot.lastRows = slot.term.rows; - adapter?.resolveLeaf(p.leafId)?.resizePty(slot.lastCols, slot.lastRows); + commitPendingResize(slot, p.leafId); }; slot.observer = new ResizeObserver(() => { @@ -647,6 +659,15 @@ function serializeSlot(slot: Slot): SerializeOutput { } function detachSlotFromLeaf(slot: Slot, retain: boolean): void { + // Commit any debounced-but-unsent resize before tearing down the timers. + // Dropping it here (the old behavior) left the PTY winsize behind the + // rendered grid, so tmux drew pane content into cells that no longer lined up + // with the grid and it bled across the pane dividers. releaseSlot reports + // slot.term.cols/rows as the session's size, so the PTY must already match. + if (slot.currentLeafId !== null) { + commitPendingResize(slot, slot.currentLeafId); + } + if (retain && slot.currentLeafId !== null) { slot.retainedLeafId = slot.currentLeafId; parkSlotHost(slot); diff --git a/src/modules/terminal/lib/rendererResize.test.ts b/src/modules/terminal/lib/rendererResize.test.ts new file mode 100644 index 000000000..e0db4e2c8 --- /dev/null +++ b/src/modules/terminal/lib/rendererResize.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { pendingPtyResize } from "./rendererResize"; + +describe("pendingPtyResize", () => { + it("returns null when the grid already matches the committed PTY size", () => { + expect( + pendingPtyResize({ cols: 80, rows: 24 }, { cols: 80, rows: 24 }), + ).toBeNull(); + }); + + it("reports the grid size when columns advanced past the PTY", () => { + expect( + pendingPtyResize({ cols: 100, rows: 24 }, { cols: 80, rows: 24 }), + ).toEqual({ cols: 100, rows: 24 }); + }); + + it("reports the grid size when rows advanced past the PTY", () => { + expect( + pendingPtyResize({ cols: 80, rows: 30 }, { cols: 80, rows: 24 }), + ).toEqual({ cols: 80, rows: 30 }); + }); + + it("reports a shrink as readily as a grow", () => { + expect( + pendingPtyResize({ cols: 60, rows: 20 }, { cols: 80, rows: 24 }), + ).toEqual({ cols: 60, rows: 20 }); + }); + + it("never emits a degenerate (zero/negative) resize during teardown", () => { + expect( + pendingPtyResize({ cols: 0, rows: 24 }, { cols: 80, rows: 24 }), + ).toBeNull(); + expect( + pendingPtyResize({ cols: 80, rows: 0 }, { cols: 80, rows: 24 }), + ).toBeNull(); + expect( + pendingPtyResize({ cols: -1, rows: 24 }, { cols: 80, rows: 24 }), + ).toBeNull(); + }); + + // Regression: tmux pane-bleed. A container resize fits the grid to 100x30 but + // the leaf is released before the debounced PTY-resize timer fires. The + // pending delta must be surfaced so the caller can commit it; a dropped + // resize leaves the PTY winsize behind the grid and tmux smears pane content + // across the dividers. + it("surfaces a pending resize an interrupted debounce would have dropped", () => { + const grid = { cols: 100, rows: 30 }; + const lastCommittedToPty = { cols: 80, rows: 24 }; + expect(pendingPtyResize(grid, lastCommittedToPty)).toEqual({ + cols: 100, + rows: 30, + }); + }); +}); diff --git a/src/modules/terminal/lib/rendererResize.ts b/src/modules/terminal/lib/rendererResize.ts new file mode 100644 index 000000000..862c64810 --- /dev/null +++ b/src/modules/terminal/lib/rendererResize.ts @@ -0,0 +1,21 @@ +export type GridSize = { cols: number; rows: number }; + +// A terminal slot's rendered grid is refit synchronously on every container +// resize, but the matching PTY winsize update is debounced. This returns the +// dimensions still owed to the PTY, or null when the two already agree. +// +// The caller must commit this delta on EVERY teardown path, not only when the +// debounce timer fires. If a leaf is released or evicted mid-debounce the +// pending resize used to be dropped, leaving the PTY winsize behind the grid. +// A multiplexer like tmux positions every pane by absolute coordinates against +// the size it was told, so a stale winsize makes it draw pane content into +// cells that no longer line up with the grid: content bleeds across the pane +// dividers. Degenerate (<= 0) dimensions never produce a resize. +export function pendingPtyResize( + grid: GridSize, + committed: GridSize, +): GridSize | null { + if (grid.cols <= 0 || grid.rows <= 0) return null; + if (grid.cols === committed.cols && grid.rows === committed.rows) return null; + return { cols: grid.cols, rows: grid.rows }; +}