Compare commits

...

2 Commits

Author SHA1 Message Date
nntrivi2001 c41114faf8 chore: v2.8.42 — terminal glyphs stay sharp after the WebGL atlas fills up 2026-10-30 10:10:33 +07:00
nntrivi2001 c850f7603a fix(terminal): re-upload the shared WebGL atlas after a page merge
@xterm/addon-webgl 0.19 shares one glyph atlas between every terminal
with the same font. When it fills, four pages are merged into a larger
one and the rest shift down; a GPU texture is re-uploaded only when its
page version differs, and those counters can coincide, so a slot keeps a
stale page of another size and every glyph is drawn squeezed or foreign
(fixed upstream only in the 0.20 betas). On a merge — and when the page
comes back — toggle the font family's trailing space on the next frame,
which makes each renderer re-acquire its atlas and re-upload all pages.

This also drops yesterday's clearTextureAtlas on focus: it emptied the
SHARED atlas but redrew only the calling pane, corrupting its siblings.
2026-10-30 10:09:28 +07:00
5 changed files with 56 additions and 12 deletions
+27 -6
View File
@@ -762,13 +762,32 @@ export const TerminalView = memo(function TerminalView({
refit();
};
document.fonts?.addEventListener("loadingdone", onFontLoaded);
// The browser may drop or reuse the WebGL glyph atlas's GPU memory without
// a context-loss event (sleep/resume, a long-backgrounded tab, a display or
// zoom change on macOS): xterm keeps slicing the old coordinates and every
// cell shows a clipped or foreign glyph while the buffer stays correct.
// Rebuilding the atlas when the page comes back is cheap and redraws clean.
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
// with the same font, and when it fills up it merges four pages into one
// larger page and shifts the rest down. A GPU texture is re-uploaded only
// when its page's `version` differs from the uploaded one, and after a
// shift those counters can coincide, so a slot keeps a stale page of
// another size: every glyph is sampled squeezed or foreign while the
// buffer stays correct (fixed upstream only in 0.20 betas). Any option
// change makes each renderer re-acquire its atlas and re-upload all pages,
// so this toggles the family's trailing space (as onFontLoaded does) on
// a merge. `clearTextureAtlas` is no fix: it empties the SHARED atlas
// but redraws only this pane, leaving its siblings slicing reused space.
let atlasFrame = 0;
const rebuildAtlas = () => {
if (atlasFrame) return;
// Never inside the render that is rasterizing the merging glyph.
atlasFrame = requestAnimationFrame(() => {
atlasFrame = 0;
const family = term.options.fontFamily ?? "";
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
});
};
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
// The browser may also drop the textures without a context-loss event
// (sleep/resume, a long-backgrounded tab): rebuild when the page is back.
const onPageShown = () => {
if (document.visibilityState === "visible") term.clearTextureAtlas();
if (document.visibilityState === "visible") rebuildAtlas();
};
document.addEventListener("visibilitychange", onPageShown);
window.addEventListener("focus", onPageShown);
@@ -790,6 +809,8 @@ export const TerminalView = memo(function TerminalView({
searchRef.current = null;
resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
if (atlasFrame) cancelAnimationFrame(atlasFrame);
atlasMerged?.dispose();
document.removeEventListener("visibilitychange", onPageShown);
window.removeEventListener("focus", onPageShown);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
@@ -112,12 +112,15 @@ vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
// The newest WebGL addon, with the context-loss listener the view gave it.
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
const gpu: {
last: { dispose: ReturnType<typeof vi.fn>; lose: () => void; merge: () => void } | null;
} = {
last: null,
};
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {};
let onMerge = () => {};
const addon = {
__webgl: true,
dispose: vi.fn(),
@@ -125,8 +128,12 @@ vi.mock("@xterm/addon-webgl", () => ({
onLoss = fn;
return { dispose: vi.fn() };
},
onRemoveTextureAtlasCanvas: (fn: () => void) => {
onMerge = fn;
return { dispose: vi.fn() };
},
};
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
gpu.last = { dispose: addon.dispose, lose: () => onLoss(), merge: () => onMerge() };
return addon;
}),
}));
@@ -488,6 +495,22 @@ describe("TerminalView", () => {
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
});
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
// addon-webgl 0.19 may keep a stale page in a GPU slot after it merges
// atlas pages; any option change makes it re-upload every page.
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const opts = termOptions as { fontFamily?: string };
opts.fontFamily = "JetBrains Mono";
gpu.last!.merge();
gpu.last!.merge(); // coalesced into one frame
expect(raf).toHaveBeenCalledTimes(1);
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
expect(opts.fontFamily).toBe("JetBrains Mono ");
raf.mockRestore();
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.41 (rebuild client to match)
UI build v2.8.42 (rebuild client to match)
</span>
</p>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.41",
"version": "2.8.42",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.41",
"version": "2.8.42",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.41",
"version": "2.8.42",
"bin": {
"agenthub": "bin/agenthub.js"
},