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:
@@ -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();
|
||||
|
||||
113
web/src/lib/terminal-font-refit.test.ts
Normal file
113
web/src/lib/terminal-font-refit.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
71
web/src/lib/terminal-font-refit.ts
Normal file
71
web/src/lib/terminal-font-refit.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user