Skip to content
Open
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
31 changes: 26 additions & 5 deletions src/modules/terminal/lib/rendererPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
terminalLineNavigationSequence,
terminalWordNavigationSequence,
} from "./keymap";
import { pendingPtyResize } from "./rendererResize";

export const POOL_MAX_SIZE = 5;
const FIT_DEBOUNCE_MS = 8;
Expand Down Expand Up @@ -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);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 54 additions & 0 deletions src/modules/terminal/lib/rendererResize.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
21 changes: 21 additions & 0 deletions src/modules/terminal/lib/rendererResize.ts
Original file line number Diff line number Diff line change
@@ -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 };
}