fix(frontend): recover clipped Codex terminal via onRender convergence re-fit (#312)
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 <noreply@anthropic.com>
This commit is contained in:
parent
dd3faa7357
commit
63d488ba29
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue