fix(terminal): fit the terminal only once the terminal font is measurable

Opening a session could render its frame with characters spliced into each
other, as though two frames were overlaid — a status-line fragment landing
in the middle of a file path, for instance. Resizing the browser window
cleared it.

The first fit runs while the browser is still painting with a fallback font.
A cell measured against that font has a different width and height from one
measured against the terminal font, so the fit produces the wrong column and
row count. Codeman sizes the pane to it and replays the capture. When the
font finishes loading the measurement changes, the pane is resized a second
time, and the CLI repaints for a shape that does not match the frame already
on screen. Its later partial updates then land on the wrong rows.

selectSession now waits for the font before it measures, so the pane is
sized once, at the size that sticks, and the capture is taken at that size.
The wait always resolves, so a font that never loads cannot block a
terminal, and it resolves immediately once the font is in, so a tab switch
pays nothing after the first load.

document.fonts.ready alone is not enough: it can resolve before the
stylesheet declaring @font-face has been parsed. document.fonts.load for
each family in the stack is what actually requests the faces.

Measured on a session opening at 2328px wide: the cell went from 8.43x16.00
to 8.00x21.00 roughly 900ms in, moving the grid from 112x36 to 118x28 after
the replay had already been painted.
This commit is contained in:
Michael Grundberg
2026-09-09 14:20:34 +02:00
parent 5130ca6633
commit 0e82443222
2 changed files with 48 additions and 0 deletions
+13
View File
@@ -5926,6 +5926,19 @@ class CodemanApp {
let bufferWasEmpty = false;
let cacheResetAndParseMs = 0;
try {
// Hold for the terminal font before measuring anything. A cell measured
// against a fallback font gives the wrong column and row count, and the
// correction lands after the replay, leaving the CLI drawing against a
// frame the terminal no longer shows. Resolves immediately once the font
// is in, so this costs a tab switch nothing after the first load.
if (this._terminalFontReady) {
await this._terminalFontReady;
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
}
}
// Fit terminal to container BEFORE writing any buffer data.
// If the browser was resized while viewing another session, the terminal
// canvas may be at stale dimensions — content would render at wrong width.
+35
View File
@@ -514,6 +514,10 @@ Object.assign(CodemanApp.prototype, {
} else {
this.fitAddon.fit();
}
// That first fit measures whatever font the browser has painted with so far,
// which is not necessarily the terminal font. Start the wait now so the
// buffer load can hold for it.
this._terminalFontReady = this._awaitTerminalFont();
// Register link provider for clickable file paths in Bash tool output
this.registerFilePathLinkProvider();
@@ -4806,6 +4810,37 @@ Object.assign(CodemanApp.prototype, {
* Prevents extremely narrow terminals that cause vertical text wrapping.
* @returns {{cols: number, rows: number}|null}
*/
/**
* Resolve once the terminal's own font is loaded and measurable.
*
* A character cell measured against a fallback font has a different width and
* height from one measured against the terminal font, so a fit taken too early
* produces the wrong column and row count. The correction then arrives after
* the buffer has been replayed, and the CLI redraws a frame that no longer
* matches what the terminal is showing.
*
* `document.fonts.ready` is not enough on its own: it can resolve before the
* stylesheet declaring @font-face has even been parsed. `document.fonts.load`
* for each family in the stack is what actually requests the faces, so this
* asks for those first and only then waits for the document to go quiet.
* Every step is best-effort — a font that never loads must not block the
* terminal, so this always resolves.
*/
async _awaitTerminalFont() {
try {
if (typeof document === 'undefined' || !document.fonts?.load) return;
const size = this.terminal?.options?.fontSize || 14;
const families = String(this.terminal?.options?.fontFamily || '')
.split(',')
.map((family) => family.trim())
.filter(Boolean);
await Promise.all(families.map((family) => document.fonts.load(`${size}px ${family}`).catch(() => {})));
await document.fonts.ready;
} catch {
/* font loading is unavailable or failed — fit against whatever is painted */
}
},
getTerminalDimensions() {
const MIN_COLS = 40;
const MIN_ROWS = 10;