From fc1864279b72e945324cc722af2f5c1045750022 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Thu, 18 Jun 2026 15:44:08 +0530 Subject: [PATCH] fix(frontend): recover clipped Codex terminal via onRender convergence re-fit The Codex terminal rendered only in the top half of the pane: FitAddon divided the pane height by a too-tall cell box (measured before the post-open WebGL renderer and the monospace font's real metrics resolved), under-counted rows, and sent that short grid to zellij. It never recovered because every remaining fit trigger after the settle window was the host ResizeObserver, and the host's height:100% box never changes when only the row count is wrong. Add an onRender convergence loop: each renderer repaint re-proposes dimensions from the current measured cell box and re-fits when they differ, converging the grid to the true row count once metrics settle, then detaches once stable (bounded by a re-fit cap). proposeDimensions returns undefined until the cell box is non-zero, so a fit is never accepted from an unmeasured cell. Also listen on window resize for OS-window / DPR changes that move the true cell box without touching the host box. Fixes #280 Co-Authored-By: Claude Opus 4.8 --- .../src/renderer/components/XtermTerminal.tsx | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index fe1e3cf0..5f58f010 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -8,12 +8,15 @@ // - Nothing writes into the buffer at mount. Status/empty-state belongs to DOM // chrome around the terminal, not inside it. Writing before layout settles // is what crashed xterm's Viewport (`dimensions` of a zero-sized renderer). -// - Fitting runs on several triggers, not one: FitAddon derives the column -// count from measured cell width, and if it measures before the monospace -// font's real metrics are resolved it over-counts columns and the grid -// overflows the panel. So: next frame, two settle timeouts, fonts.ready, -// and a ResizeObserver. xterm itself only fires onResize when the grid -// actually changed, so repeated fits don't spam the PTY. +// - Fitting runs on several triggers, not one: FitAddon derives the grid from +// the measured cell box, and if it measures before the monospace font's real +// metrics (and the post-open renderer) are resolved it mis-counts cols/rows +// and the grid clips inside the panel. So: next frame, two settle timeouts, +// fonts.ready, a ResizeObserver, AND an onRender convergence loop that +// re-fits until the proposed grid stops changing (the last is the only +// trigger that recovers a clipped grid without the host box resizing). xterm +// itself only fires onResize when the grid actually changed, so repeated +// fits don't spam the PTY. import { useEffect, useRef } from "react"; import { Terminal } from "@xterm/xterm"; @@ -160,6 +163,50 @@ export function XtermTerminal(props: XtermTerminalProps) { const observer = new ResizeObserver(fitTerminal); observer.observe(host); + // 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 + // 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 + // correct it because the host's pixel box is a stable height:100%, so a + // short 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. + const STABLE_FRAMES_TARGET = 3; + const MAX_REFITS = 20; + let stableFrames = 0; + let refits = 0; + 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(); + return; + } + stableFrames = 0; + fitTerminal(); + return; + } + if (++stableFrames >= STABLE_FRAMES_TARGET) stabilizer.dispose(); + }); + + // OS window resize and monitor/DPR changes also alter the true cell box + // without touching the host's height:100% box, so the ResizeObserver above + // misses them. Listen on window directly as a session-long recovery path. + window.addEventListener("resize", fitTerminal); + // Live cols/rows getters: the owner reads the current grid at attach time, // not a snapshot taken at ready time (the first fit may not have run yet). const handle: AttachableTerminal = { @@ -182,6 +229,8 @@ export function XtermTerminal(props: XtermTerminalProps) { cancelAnimationFrame(raf); for (const timer of settleTimers) window.clearTimeout(timer); observer.disconnect(); + stabilizer.dispose(); + window.removeEventListener("resize", fitTerminal); try { term.dispose(); } catch {