From d4c5fb1399ce87cc032d01e3942c879364c84706 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 16:08:40 +0530 Subject: [PATCH] fix(frontend): stop fit loop committing transient cell-box mis-measurements (#313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onRender convergence loop added in #312 recovered the under-counted rows from #280, but on a HiDPI display it could lock the Codex orchestrator terminal at half size with a ghosted composer (#313). FitAddon derives the grid by dividing the pane box by the renderer's measured css cell box. During the WebGL atlas warm-up that cell box can emit a one-frame transient (a doubled box on a 2x display), which halves the proposed cols/rows. The loop committed that single frame's proposal, resized the grid to half, then detached after a few "stable" frames — so nothing re-fired the PTY resize that makes zellij repaint, leaving the grid stuck at half width and the stale composer un-cleared. Require a differing proposal to repeat identically across two consecutive renders before applying it, so a one-frame transient only updates the pending value and is never committed. Add 600ms/1200ms settle fits as a session-bounded backstop: by then the atlas and font metrics are warm, so even if the loop detached at a briefly-stable wrong measurement, a late re-measure corrects the grid and fires the resize zellij needs to repaint cleanly. fit() is idempotent, so a correct terminal never reflows. Fixes #313 Co-Authored-By: Claude Opus 4.8 --- .../src/renderer/components/XtermTerminal.tsx | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index 5f58f010..9b177b4c 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -156,7 +156,14 @@ export function XtermTerminal(props: XtermTerminalProps) { }; const raf = requestAnimationFrame(fitTerminal); - const settleTimers = [window.setTimeout(fitTerminal, 50), window.setTimeout(fitTerminal, 250)]; + // 50/250ms catch the common settle; 600/1200ms are a session-bounded + // backstop. By 600ms the WebGL atlas and font metrics are unambiguously + // warm, so even if the convergence loop below detached at a briefly-stable + // wrong measurement, this re-measures the real cell box and corrects — + // which fires the PTY resize that makes zellij repaint cleanly (clearing + // any ghost frame). fit() is idempotent: a no-op when the grid is already + // right, so a correct terminal never reflows. + const settleTimers = [50, 250, 600, 1200].map((ms) => window.setTimeout(fitTerminal, ms)); if (document.fonts?.ready) { void document.fonts.ready.then(fitTerminal); } @@ -165,40 +172,53 @@ export function XtermTerminal(props: XtermTerminalProps) { // Recovery re-fit that does NOT depend on the host box changing size. // - // FitAddon derives the row count by dividing the pane height by the - // renderer's measured cell box. That box is measured asynchronously: the - // WebGL renderer loads after open() and the monospace font's real metrics + // FitAddon derives the grid by dividing the pane box by the renderer's + // measured cell box. That box is measured asynchronously: the WebGL + // renderer loads after open() and the monospace font's real metrics // resolve a frame or more later, so the early fits above can divide by a - // too-tall cell height, under-count rows, and clip the grid to the top of - // the pane. The fixed settle window (rAF, timeouts, fonts.ready) may all - // run before the cell box is final, and the ResizeObserver never fires to + // not-yet-final cell box, mis-count cols/rows, and clip the grid inside the + // pane. The fixed settle window (rAF, timeouts, fonts.ready) may all run + // before the cell box is final, and the ResizeObserver never fires to // correct it because the host's pixel box is a stable height:100%, so a - // short grid would otherwise freeze for the whole session. + // wrong grid would otherwise freeze for the whole session. // // onRender fires on every renderer repaint, including the repaint after // the metrics settle. Each fire re-proposes dimensions from the *current* - // measured cell box and re-fits when they differ, converging the grid to - // the true row count once the cell height is real. proposeDimensions - // returns undefined until the cell box is non-zero, so a fit is never - // accepted from an unmeasured cell. Once the proposal holds for a few - // frames (or a hard re-fit cap is hit) the listener detaches, so - // steady-state content renders cost nothing. + // measured cell box. Crucially we never re-fit straight off a single + // frame's proposal: the WebGL atlas warm-up can emit a one-frame transient + // cell box (e.g. a doubled box on a HiDPI display) that halves the grid, + // and committing it would lock the terminal at half size and detach (the + // #313 ghost). So a differing proposal must REPEAT identically across two + // consecutive renders — proving the measurement settled — before we apply + // it. proposeDimensions returns undefined until the cell box is non-zero, + // so a fit is never accepted from an unmeasured cell. Once the proposal + // holds at the live grid for a few frames (or a hard re-fit cap is hit) the + // listener detaches, so steady-state content renders cost nothing. const STABLE_FRAMES_TARGET = 3; const MAX_REFITS = 20; let stableFrames = 0; let refits = 0; + let pending: { cols: number; rows: number } | null = null; const stabilizer = term.onRender(() => { const proposed = fit.proposeDimensions(); if (!proposed || !proposed.cols || !proposed.rows) return; if (proposed.cols !== term.cols || proposed.rows !== term.rows) { - if (refits++ >= MAX_REFITS) { - stabilizer.dispose(); + stableFrames = 0; + // Only act once the same differing proposal repeats — a single-frame + // transient never gets committed, it just updates `pending`. + if (pending && pending.cols === proposed.cols && pending.rows === proposed.rows) { + pending = null; + if (refits++ >= MAX_REFITS) { + stabilizer.dispose(); + return; + } + fitTerminal(); return; } - stableFrames = 0; - fitTerminal(); + pending = { cols: proposed.cols, rows: proposed.rows }; return; } + pending = null; if (++stableFrames >= STABLE_FRAMES_TARGET) stabilizer.dispose(); });