Skip to content

Commit 93123ed

Browse files
fix(frontend): stop fit loop committing transient cell-box mis-measurements (#313) (#316)
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 <noreply@anthropic.com>
1 parent 63d488b commit 93123ed

1 file changed

Lines changed: 38 additions & 18 deletions

File tree

frontend/src/renderer/components/XtermTerminal.tsx

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,14 @@ export function XtermTerminal(props: XtermTerminalProps) {
156156
};
157157

158158
const raf = requestAnimationFrame(fitTerminal);
159-
const settleTimers = [window.setTimeout(fitTerminal, 50), window.setTimeout(fitTerminal, 250)];
159+
// 50/250ms catch the common settle; 600/1200ms are a session-bounded
160+
// backstop. By 600ms the WebGL atlas and font metrics are unambiguously
161+
// warm, so even if the convergence loop below detached at a briefly-stable
162+
// wrong measurement, this re-measures the real cell box and corrects —
163+
// which fires the PTY resize that makes zellij repaint cleanly (clearing
164+
// any ghost frame). fit() is idempotent: a no-op when the grid is already
165+
// right, so a correct terminal never reflows.
166+
const settleTimers = [50, 250, 600, 1200].map((ms) => window.setTimeout(fitTerminal, ms));
160167
if (document.fonts?.ready) {
161168
void document.fonts.ready.then(fitTerminal);
162169
}
@@ -165,40 +172,53 @@ export function XtermTerminal(props: XtermTerminalProps) {
165172

166173
// Recovery re-fit that does NOT depend on the host box changing size.
167174
//
168-
// FitAddon derives the row count by dividing the pane height by the
169-
// renderer's measured cell box. That box is measured asynchronously: the
170-
// WebGL renderer loads after open() and the monospace font's real metrics
175+
// FitAddon derives the grid by dividing the pane box by the renderer's
176+
// measured cell box. That box is measured asynchronously: the WebGL
177+
// renderer loads after open() and the monospace font's real metrics
171178
// resolve a frame or more later, so the early fits above can divide by a
172-
// too-tall cell height, under-count rows, and clip the grid to the top of
173-
// the pane. The fixed settle window (rAF, timeouts, fonts.ready) may all
174-
// run before the cell box is final, and the ResizeObserver never fires to
179+
// not-yet-final cell box, mis-count cols/rows, and clip the grid inside the
180+
// pane. The fixed settle window (rAF, timeouts, fonts.ready) may all run
181+
// before the cell box is final, and the ResizeObserver never fires to
175182
// correct it because the host's pixel box is a stable height:100%, so a
176-
// short grid would otherwise freeze for the whole session.
183+
// wrong grid would otherwise freeze for the whole session.
177184
//
178185
// onRender fires on every renderer repaint, including the repaint after
179186
// the metrics settle. Each fire re-proposes dimensions from the *current*
180-
// measured cell box and re-fits when they differ, converging the grid to
181-
// the true row count once the cell height is real. proposeDimensions
182-
// returns undefined until the cell box is non-zero, so a fit is never
183-
// accepted from an unmeasured cell. Once the proposal holds for a few
184-
// frames (or a hard re-fit cap is hit) the listener detaches, so
185-
// steady-state content renders cost nothing.
187+
// measured cell box. Crucially we never re-fit straight off a single
188+
// frame's proposal: the WebGL atlas warm-up can emit a one-frame transient
189+
// cell box (e.g. a doubled box on a HiDPI display) that halves the grid,
190+
// and committing it would lock the terminal at half size and detach (the
191+
// #313 ghost). So a differing proposal must REPEAT identically across two
192+
// consecutive renders — proving the measurement settled — before we apply
193+
// it. proposeDimensions returns undefined until the cell box is non-zero,
194+
// so a fit is never accepted from an unmeasured cell. Once the proposal
195+
// holds at the live grid for a few frames (or a hard re-fit cap is hit) the
196+
// listener detaches, so steady-state content renders cost nothing.
186197
const STABLE_FRAMES_TARGET = 3;
187198
const MAX_REFITS = 20;
188199
let stableFrames = 0;
189200
let refits = 0;
201+
let pending: { cols: number; rows: number } | null = null;
190202
const stabilizer = term.onRender(() => {
191203
const proposed = fit.proposeDimensions();
192204
if (!proposed || !proposed.cols || !proposed.rows) return;
193205
if (proposed.cols !== term.cols || proposed.rows !== term.rows) {
194-
if (refits++ >= MAX_REFITS) {
195-
stabilizer.dispose();
206+
stableFrames = 0;
207+
// Only act once the same differing proposal repeats — a single-frame
208+
// transient never gets committed, it just updates `pending`.
209+
if (pending && pending.cols === proposed.cols && pending.rows === proposed.rows) {
210+
pending = null;
211+
if (refits++ >= MAX_REFITS) {
212+
stabilizer.dispose();
213+
return;
214+
}
215+
fitTerminal();
196216
return;
197217
}
198-
stableFrames = 0;
199-
fitTerminal();
218+
pending = { cols: proposed.cols, rows: proposed.rows };
200219
return;
201220
}
221+
pending = null;
202222
if (++stableFrames >= STABLE_FRAMES_TARGET) stabilizer.dispose();
203223
});
204224

0 commit comments

Comments
 (0)