Merge remote-tracking branch 'origin/main' into ethie/pm-clean

# Conflicts:
#	apps/desktop/src/app/settings/connections-registry.tsx
#	scripts/install.ps1
#	scripts/install.sh
#	tests/hermes_cli/test_update_autostash.py
This commit is contained in:
ethernet
2026-09-24 03:48:31 -04:00
140 changed files with 6586 additions and 472 deletions

View File

@@ -12,6 +12,10 @@ import { useModalBehavior } from "@/hooks/useModalBehavior";
import { useProfileScope } from "@/contexts/useProfileScope";
import { api } from "@/lib/api";
import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload";
import {
refitWhenTerminalFontLoads,
TERMINAL_FONT_FAMILY,
} from "@/lib/terminal-font-refit";
import { cn, themedBody } from "@/lib/utils";
import { useTheme } from "@/themes";
import { errorMessage } from "@/lib/api-error";
@@ -351,8 +355,7 @@ export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) {
const term = new XtermTerminal({
allowProposedApi: true,
cursorBlink: true,
fontFamily:
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
fontFamily: TERMINAL_FONT_FAMILY,
fontSize: 13,
lineHeight: 1.25,
letterSpacing: 0,
@@ -394,6 +397,7 @@ export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) {
const ro = new ResizeObserver(scheduleFit);
ro.observe(host);
scheduleFit();
const stopFontRefit = refitWhenTerminalFontLoads(term, fitTerminal);
const dataDisposable = term.onData(handleInputData);
setConnectionState("connecting");
@@ -462,6 +466,7 @@ export function HermesConsoleModal({ open, onClose }: HermesConsoleModalProps) {
dataDisposable.dispose();
ro.disconnect();
if (resizeFrame) cancelAnimationFrame(resizeFrame);
stopFontRefit();
wsRef.current?.close();
wsRef.current = null;
term.dispose();

View File

@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";
import {
refitWhenTerminalFontLoads,
TERMINAL_FONT_FAMILY,
} from "./terminal-font-refit";
/** Mirrors xterm: the cell is re-measured only when fontFamily changes. */
function fakeTerminal() {
let family = TERMINAL_FONT_FAMILY;
const term = {
measuredWith: [] as string[],
options: {
get fontFamily() {
return family;
},
set fontFamily(next: string) {
if (next === family) return;
family = next;
term.measuredWith.push(next);
},
},
rows: 24,
clearTextureAtlas: vi.fn(),
refresh: vi.fn(),
};
return term;
}
function deferredFontSet(opts: { loaded?: boolean; faces?: unknown[] } = {}) {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
return {
release,
fontSet: {
check: vi.fn(() => opts.loaded ?? false),
load: vi.fn(async () => {
await gate;
return (opts.faces ?? [{}]) as FontFace[];
}),
},
};
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("refitWhenTerminalFontLoads", () => {
it("re-measures with the loaded face, then refits and redraws", async () => {
const term = fakeTerminal();
const fit = vi.fn();
const { fontSet, release } = deferredFontSet();
refitWhenTerminalFontLoads(term, fit, fontSet);
await settle();
expect(fit).not.toHaveBeenCalled();
release();
await settle();
expect(term.measuredWith.at(-1)).toBe(TERMINAL_FONT_FAMILY);
expect(term.options.fontFamily).toBe(TERMINAL_FONT_FAMILY);
expect(fit).toHaveBeenCalledTimes(1);
expect(term.clearTextureAtlas).toHaveBeenCalledTimes(1);
expect(term.refresh).toHaveBeenCalledWith(0, 23);
});
it("does nothing after cleanup runs before the font arrives", async () => {
const term = fakeTerminal();
const fit = vi.fn();
const { fontSet, release } = deferredFontSet();
const cleanup = refitWhenTerminalFontLoads(term, fit, fontSet);
cleanup();
release();
await settle();
expect(term.measuredWith).toEqual([]);
expect(fit).not.toHaveBeenCalled();
});
it("skips the refit when the faces were already loaded at open()", async () => {
const term = fakeTerminal();
const fit = vi.fn();
const { fontSet } = deferredFontSet({ loaded: true });
refitWhenTerminalFontLoads(term, fit, fontSet);
await settle();
expect(fontSet.load).not.toHaveBeenCalled();
expect(fit).not.toHaveBeenCalled();
});
it("leaves fallback metrics alone when no bundled face could load", async () => {
const term = fakeTerminal();
const fit = vi.fn();
const { fontSet, release } = deferredFontSet({ faces: [] });
refitWhenTerminalFontLoads(term, fit, fontSet);
release();
await settle();
expect(term.measuredWith).toEqual([]);
expect(fit).not.toHaveBeenCalled();
});
it("is a no-op without a FontFaceSet", () => {
const fit = vi.fn();
expect(() => refitWhenTerminalFontLoads(fakeTerminal(), fit, undefined)()).not.toThrow();
expect(fit).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,71 @@
/**
* Dashboard xterm font stack. 'JetBrains Mono' is bundled via @font-face in
* index.css with `font-display: swap`, so it is usually still downloading
* when a terminal first opens.
*/
export const TERMINAL_FONT_FAMILY =
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace";
const BUNDLED_FACES = ["400", "700", "italic 400"].map(
(descriptor) => `${descriptor} 1em 'JetBrains Mono'`,
);
type TerminalFontSet = Pick<FontFaceSet, "check" | "load">;
export interface RemeasurableTerminal {
options: { fontFamily?: string };
rows: number;
clearTextureAtlas(): void;
refresh(start: number, end: number): void;
}
function browserFontSet(): TerminalFontSet | undefined {
return typeof document === "undefined" ? undefined : document.fonts;
}
/**
* xterm measures its cell size once at open() and afterwards only when
* fontFamily/fontSize *change* or the grid resizes. When the bundled font
* swaps in later, the grid keeps fallback-font metrics (and the WebGL atlas
* keeps fallback glyphs) until something resizes the host, e.g. toggling the
* sidebar (#92899). Once the bundled faces load, force a re-measure, refit,
* and redraw. Returns a cleanup that drops a still-pending load.
*/
export function refitWhenTerminalFontLoads(
term: RemeasurableTerminal,
fit: () => void,
fontSet: TerminalFontSet | undefined = browserFontSet(),
): () => void {
if (!fontSet?.load) return () => undefined;
try {
if (BUNDLED_FACES.every((face) => fontSet.check(face))) {
return () => undefined;
}
} catch {
/* check() throws on unparsable descriptors in some engines; just load */
}
let cancelled = false;
void Promise.allSettled(
BUNDLED_FACES.map((face) => Promise.resolve().then(() => fontSet.load(face))),
).then((results) => {
if (cancelled) return;
const loaded = results.some(
(r) => r.status === "fulfilled" && r.value.length > 0,
);
if (!loaded) return;
// A same-value assignment is a no-op in xterm, so bounce through a
// generic family to make it re-measure against the loaded face.
const family = term.options.fontFamily ?? TERMINAL_FONT_FAMILY;
term.options.fontFamily = "monospace";
term.options.fontFamily = family;
fit();
term.clearTextureAtlas();
if (term.rows > 0) term.refresh(0, term.rows - 1);
});
return () => {
cancelled = true;
};
}

View File

@@ -43,6 +43,8 @@ class FakeTerminal {
clearSelection() {}
clearTextureAtlas() {}
dispose() {}
focus() {}
@@ -579,6 +581,46 @@ describe("ChatPage side panel collapse", () => {
// (that timer is set after `new WebSocket`). Without its own deadline the tab
// strands on "connecting" with no retry. Mirrors the ChatSidebar events-feed
// coverage in src/components/ChatSidebar.test.tsx.
describe("ChatPage bundled font swap-in", () => {
it("redraws the terminal with the bundled font once it finishes loading", async () => {
let releaseFont!: () => void;
const fontGate = new Promise<void>((resolve) => {
releaseFont = resolve;
});
Object.defineProperty(document, "fonts", {
configurable: true,
value: {
check: () => false,
load: async () => {
await fontGate;
return [{}];
},
},
});
const clearAtlas = vi.spyOn(FakeTerminal.prototype, "clearTextureAtlas");
try {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
expect(clearAtlas).not.toHaveBeenCalled();
await act(async () => {
releaseFont();
await fontGate;
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(clearAtlas).toHaveBeenCalledTimes(1);
} finally {
clearAtlas.mockRestore();
delete (document as { fonts?: unknown }).fonts;
}
});
});
describe("ChatPage PTY ticket connect deadline", () => {
beforeEach(() => {
vi.useFakeTimers();

View File

@@ -94,6 +94,10 @@ import {
type PtyBannerAction,
} from "@/lib/pty-close-copy";
import { ptyAttachToken } from "@/lib/pty-attach-token";
import {
refitWhenTerminalFontLoads,
TERMINAL_FONT_FAMILY,
} from "@/lib/terminal-font-refit";
import { loseWebglContexts } from "@/lib/xterm-webgl-release";
import { PluginSlot } from "@/plugins";
import { useTheme } from "@/themes";
@@ -579,8 +583,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const term = new Terminal({
allowProposedApi: true,
cursorBlink: true,
fontFamily:
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
fontFamily: TERMINAL_FONT_FAMILY,
fontSize: terminalFontSizeForWidth(tierW0),
lineHeight: terminalLineHeightForWidth(tierW0),
letterSpacing: 0,
@@ -1112,6 +1115,10 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
});
});
// The rAF fits above still measure the fallback font if JetBrains Mono
// hasn't swapped in yet (#92899).
const stopFontRefit = refitWhenTerminalFontLoads(term, syncTerminalMetrics);
// WebSocket. In gated mode (``window.__HERMES_AUTH_REQUIRED__``) this
// awaits a single-use ticket via /api/auth/ws-ticket before opening;
// in loopback mode it resolves synchronously against the injected
@@ -1607,6 +1614,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
if (settleRaf1) cancelAnimationFrame(settleRaf1);
if (settleRaf2) cancelAnimationFrame(settleRaf2);
stopFontRefit();
clearReconnectTimer();
clearConnectingTimer();
clearTicketTimer();