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(); refit();
}; };
document.fonts?.addEventListener("loadingdone", onFontLoaded); document.fonts?.addEventListener("loadingdone", onFontLoaded);
// The browser may drop or reuse the WebGL glyph atlas's GPU memory without // @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
// a context-loss event (sleep/resume, a long-backgrounded tab, a display or // with the same font, and when it fills up it merges four pages into one
// zoom change on macOS): xterm keeps slicing the old coordinates and every // larger page and shifts the rest down. A GPU texture is re-uploaded only
// cell shows a clipped or foreign glyph while the buffer stays correct. // when its page's `version` differs from the uploaded one, and after a
// Rebuilding the atlas when the page comes back is cheap and redraws clean. // 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 = () => { const onPageShown = () => {
if (document.visibilityState === "visible") term.clearTextureAtlas(); if (document.visibilityState === "visible") rebuildAtlas();
}; };
document.addEventListener("visibilitychange", onPageShown); document.addEventListener("visibilitychange", onPageShown);
window.addEventListener("focus", onPageShown); window.addEventListener("focus", onPageShown);
@@ -790,6 +809,8 @@ export const TerminalView = memo(function TerminalView({
searchRef.current = null; searchRef.current = null;
resizeObserver.disconnect(); resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded); document.fonts?.removeEventListener("loadingdone", onFontLoaded);
if (atlasFrame) cancelAnimationFrame(atlasFrame);
atlasMerged?.dispose();
document.removeEventListener("visibilitychange", onPageShown); document.removeEventListener("visibilitychange", onPageShown);
window.removeEventListener("focus", onPageShown); window.removeEventListener("focus", onPageShown);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref); window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
@@ -112,12 +112,15 @@ vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })), FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
})); }));
// The newest WebGL addon, with the context-loss listener the view gave it. // 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, last: null,
}; };
vi.mock("@xterm/addon-webgl", () => ({ vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => { WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {}; let onLoss = () => {};
let onMerge = () => {};
const addon = { const addon = {
__webgl: true, __webgl: true,
dispose: vi.fn(), dispose: vi.fn(),
@@ -125,8 +128,12 @@ vi.mock("@xterm/addon-webgl", () => ({
onLoss = fn; onLoss = fn;
return { dispose: vi.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; return addon;
}), }),
})); }));
@@ -488,6 +495,22 @@ describe("TerminalView", () => {
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on 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. // 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", () => { it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D"); expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.8.41 (rebuild client to match) UI build v2.8.42 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.41", "version": "2.8.42",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.41", "version": "2.8.42",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.41", "version": "2.8.42",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },