2026-09-14 14:09:13 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @fileoverview Terminal font weight: live apply, and the plumbing around it.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Bold text on the theme's default foreground carries exactly ONE cue, the
|
|
|
|
|
|
* weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no
|
|
|
|
|
|
* colour change, and xterm substitutes a bright colour for bold only when the
|
|
|
|
|
|
* foreground is a palette index below 8, so nothing else distinguishes it. A
|
|
|
|
|
|
* family that ships only a regular and a bold face keeps that step small, and
|
|
|
|
|
|
* 400 stays 400 whatever family is picked — which is why the NORMAL slot is
|
|
|
|
|
|
* settable at all.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Three things are pinned here because each fails silently:
|
|
|
|
|
|
*
|
|
|
|
|
|
* - A live save reaches the echo overlays and the Agent Teams panes. Both
|
|
|
|
|
|
* cache the weight (the overlays paint it into their spans, the panes read
|
|
|
|
|
|
* their options at construction), so without the propagation the characters
|
|
|
|
|
|
* being typed, or a pane left open across the save, keep the old weight
|
|
|
|
|
|
* beside a repainted terminal.
|
|
|
|
|
|
* - An unchanged save is a no-op, so opening and closing App Settings does not
|
|
|
|
|
|
* churn the terminal.
|
|
|
|
|
|
* - The bundled face is declared over its full axis. The `@font-face`
|
|
|
|
|
|
* descriptor, not the file, is what the browser synthesizes from: at
|
|
|
|
|
|
* `400 700` every weight below 400 renders identically to 400, so the
|
|
|
|
|
|
* setting would be inert for anyone without Fira Code or Cascadia Code
|
|
|
|
|
|
* installed.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
|
|
|
|
|
|
* box; see connection-indicator.test.ts), matching terminal-font-settle.test.ts.
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
|
|
import { performance } from 'node:perf_hooks';
|
|
|
|
|
|
import { resolve } from 'node:path';
|
|
|
|
|
|
import vm from 'node:vm';
|
|
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
|
|
|
|
|
|
|
|
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
|
|
|
|
|
|
|
|
|
|
|
function loadTerminalMixin(): Record<string, unknown> {
|
|
|
|
|
|
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
|
|
|
|
|
const context = vm.createContext({
|
|
|
|
|
|
console,
|
|
|
|
|
|
performance,
|
|
|
|
|
|
setTimeout,
|
|
|
|
|
|
clearTimeout,
|
|
|
|
|
|
setInterval: vi.fn(),
|
|
|
|
|
|
clearInterval: vi.fn(),
|
|
|
|
|
|
requestAnimationFrame: vi.fn(),
|
|
|
|
|
|
CodemanApp: FakeCodemanApp,
|
|
|
|
|
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
|
|
|
|
|
document: undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8');
|
|
|
|
|
|
const source = readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8');
|
|
|
|
|
|
vm.runInContext(`${constants}\n${source}`, context);
|
|
|
|
|
|
// constants.js publishes CodemanTerminalFont onto the context's window, which
|
|
|
|
|
|
// is the one the mixin closes over.
|
|
|
|
|
|
return FakeCodemanApp.prototype;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mixin = loadTerminalMixin();
|
|
|
|
|
|
|
|
|
|
|
|
function fakeTerminal(options: Record<string, unknown> = {}) {
|
|
|
|
|
|
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 08:55:05 +02:00
|
|
|
|
function makeApp(
|
|
|
|
|
|
opts: {
|
|
|
|
|
|
teammates?: number;
|
|
|
|
|
|
terminal?: ReturnType<typeof fakeTerminal> | null;
|
|
|
|
|
|
splitPane?: { terminal: ReturnType<typeof fakeTerminal>; localFit: () => void } | null;
|
|
|
|
|
|
} = {}
|
|
|
|
|
|
) {
|
2026-09-14 14:09:13 +02:00
|
|
|
|
const fit = vi.fn();
|
|
|
|
|
|
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
|
|
|
|
|
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
|
|
|
|
|
for (let i = 0; i < (opts.teammates ?? 0); i++) {
|
|
|
|
|
|
const teammateFit = vi.fn();
|
|
|
|
|
|
teammateFits.push(teammateFit);
|
|
|
|
|
|
teammateTerminals.set(`agent-${i}`, { terminal: fakeTerminal(), fitAddon: { fit: teammateFit } });
|
|
|
|
|
|
}
|
|
|
|
|
|
const app = {
|
|
|
|
|
|
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
2026-10-06 08:55:05 +02:00
|
|
|
|
// Secondary panes (the split pane's second terminal) are reached through
|
|
|
|
|
|
// this helper, so it is wired for real like the geometry chain below.
|
|
|
|
|
|
_forEachTile: mixin._forEachTile,
|
|
|
|
|
|
_splitPane: opts.splitPane ?? null,
|
2026-09-22 13:22:28 +05:30
|
|
|
|
// The REAL geometry chain, not stubs. A font change moves the cell size, so
|
|
|
|
|
|
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
|
|
|
|
|
// through the one function that floors the result and reports it (#464).
|
|
|
|
|
|
// Wiring the real methods keeps `fit` an assertion about what the terminal
|
|
|
|
|
|
// actually did rather than about which helper happened to be called.
|
|
|
|
|
|
_refitAfterCellSizeChange: mixin._refitAfterCellSizeChange,
|
|
|
|
|
|
syncTerminalGeometry: mixin.syncTerminalGeometry,
|
|
|
|
|
|
_resizeTerminalTo: mixin._resizeTerminalTo,
|
|
|
|
|
|
getTerminalDimensions: mixin.getTerminalDimensions,
|
|
|
|
|
|
// No session: `_refitAfterCellSizeChange` then refits locally and sends
|
|
|
|
|
|
// nothing, which is what these cases are about.
|
|
|
|
|
|
activeSessionId: null,
|
2026-09-14 14:09:13 +02:00
|
|
|
|
_awaitTerminalFont: vi.fn(() => Promise.resolve()),
|
|
|
|
|
|
terminal: opts.terminal === undefined ? fakeTerminal() : opts.terminal,
|
|
|
|
|
|
fitAddon: { fit },
|
|
|
|
|
|
teammateTerminals,
|
|
|
|
|
|
_localEchoOverlay: { refreshFont: vi.fn() },
|
|
|
|
|
|
_predictiveEcho: { refreshFont: vi.fn() },
|
|
|
|
|
|
_terminalFontReady: null as unknown,
|
|
|
|
|
|
};
|
|
|
|
|
|
return { app, fit, teammateFits, teammateTerminals };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
describe('applyTerminalFontWeights', () => {
|
2026-10-06 08:55:05 +02:00
|
|
|
|
it('reaches an open split pane: same weights, refit in place', () => {
|
|
|
|
|
|
const splitTerminal = fakeTerminal();
|
|
|
|
|
|
const localFit = vi.fn();
|
|
|
|
|
|
const { app } = makeApp({ splitPane: { terminal: splitTerminal, localFit } });
|
|
|
|
|
|
|
|
|
|
|
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
|
|
|
|
|
terminalFontWeight: '300',
|
|
|
|
|
|
terminalFontWeightBold: '700',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(splitTerminal.options.fontWeight).toBe(300);
|
|
|
|
|
|
expect(splitTerminal.options.fontWeightBold).toBe(700);
|
|
|
|
|
|
expect(localFit).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-14 14:09:13 +02:00
|
|
|
|
it('writes both slots to the live terminal', () => {
|
|
|
|
|
|
const { app, fit } = makeApp();
|
|
|
|
|
|
|
|
|
|
|
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
|
|
|
|
|
terminalFontWeight: '300',
|
|
|
|
|
|
terminalFontWeightBold: '800',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(app.terminal?.options.fontWeight).toBe(300);
|
|
|
|
|
|
expect(app.terminal?.options.fontWeightBold).toBe(800);
|
|
|
|
|
|
expect(fit).toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('refreshes the echo overlays, which cache the weight and paint it', () => {
|
|
|
|
|
|
// Without this the characters being typed keep the old weight while the
|
|
|
|
|
|
// rest of the screen changes — most visible on a phone, where local echo
|
|
|
|
|
|
// is on by default.
|
|
|
|
|
|
const { app } = makeApp();
|
|
|
|
|
|
|
|
|
|
|
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
|
|
|
|
|
terminalFontWeight: '300',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(app._localEchoOverlay.refreshFont).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(app._predictiveEcho.refreshFont).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('reaches Agent Teams panes, which read their options at construction', () => {
|
|
|
|
|
|
const { app, teammateTerminals, teammateFits } = makeApp({ teammates: 2 });
|
|
|
|
|
|
|
|
|
|
|
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
|
|
|
|
|
terminalFontWeight: '300',
|
|
|
|
|
|
terminalFontWeightBold: '800',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
for (const [, entry] of teammateTerminals) {
|
|
|
|
|
|
expect(entry.terminal.options.fontWeight).toBe(300);
|
|
|
|
|
|
expect(entry.terminal.options.fontWeightBold).toBe(800);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const teammateFit of teammateFits) expect(teammateFit).toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('restores xterm’s own defaults when the setting is cleared', () => {
|
|
|
|
|
|
const { app } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 800 }) });
|
|
|
|
|
|
|
|
|
|
|
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({});
|
|
|
|
|
|
|
|
|
|
|
|
expect(app.terminal?.options.fontWeight).toBe('normal');
|
|
|
|
|
|
expect(app.terminal?.options.fontWeightBold).toBe('bold');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('does nothing when neither slot changed', () => {
|
|
|
|
|
|
// saveAppSettings runs on every close of the modal.
|
|
|
|
|
|
const { app, fit } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 'bold' }) });
|
|
|
|
|
|
|
|
|
|
|
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
|
|
|
|
|
terminalFontWeight: 300,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(fit).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(app._localEchoOverlay.refreshFont).not.toHaveBeenCalled();
|
|
|
|
|
|
expect(app._awaitTerminalFont).not.toHaveBeenCalled();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('re-arms the font wait, so a fit lands once the face is rasterized', () => {
|
|
|
|
|
|
const { app } = makeApp();
|
|
|
|
|
|
|
|
|
|
|
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
|
|
|
|
|
terminalFontWeight: '300',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
expect(app._awaitTerminalFont).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
expect(app._terminalFontReady).toBeInstanceOf(Promise);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('survives a terminal that does not exist yet', () => {
|
|
|
|
|
|
const { app } = makeApp({ terminal: null });
|
|
|
|
|
|
|
|
|
|
|
|
expect(() =>
|
|
|
|
|
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
|
|
|
|
|
terminalFontWeight: '300',
|
|
|
|
|
|
})
|
|
|
|
|
|
).not.toThrow();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('bundled terminal face', () => {
|
|
|
|
|
|
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
|
|
|
|
|
|
|
|
|
|
|
it('is declared over its full weight axis, not xterm’s default span', () => {
|
|
|
|
|
|
// The woff2 carries a `wght` axis of 100 to 800. A narrower @font-face
|
|
|
|
|
|
// descriptor CLAMPS it: at `400 700`, 100/200/300 all render identically to
|
|
|
|
|
|
// 400 and 800 identically to 700, so the settings above would be a no-op
|
|
|
|
|
|
// for every install without Fira Code or Cascadia Code.
|
|
|
|
|
|
const face = styles.slice(styles.indexOf("font-family: 'JetBrains Mono'"));
|
|
|
|
|
|
const declared = /font-weight:\s*(\d+)\s+(\d+)/.exec(face.slice(0, face.indexOf('}')));
|
|
|
|
|
|
expect(declared, 'the bundled mono face must declare a weight RANGE').not.toBeNull();
|
|
|
|
|
|
expect(Number(declared![1])).toBeLessThanOrEqual(100);
|
|
|
|
|
|
expect(Number(declared![2])).toBeGreaterThanOrEqual(800);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('terminal font weight settings plumbing', () => {
|
|
|
|
|
|
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
|
|
|
|
|
|
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
|
|
|
|
|
const keys = ['terminalFontWeight', 'terminalFontWeightBold'] as const;
|
|
|
|
|
|
|
|
|
|
|
|
it('offers both selects with a Default entry and the 100-900 steps', () => {
|
|
|
|
|
|
for (const id of ['appSettingsTerminalFontWeight', 'appSettingsTerminalFontWeightBold']) {
|
|
|
|
|
|
const start = html.indexOf(`<select id="${id}"`);
|
|
|
|
|
|
expect(start, `${id} missing from index.html`).toBeGreaterThan(-1);
|
|
|
|
|
|
const select = html.slice(start, html.indexOf('</select>', start));
|
|
|
|
|
|
expect(select).toContain('<option value="">');
|
|
|
|
|
|
for (let w = 100; w <= 900; w += 100) expect(select).toContain(`<option value="${w}">`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('treats both as per-device, which is TWO separate decisions', () => {
|
|
|
|
|
|
// Membership in displayKeys keeps one device from overwriting another's
|
|
|
|
|
|
// value; the strip before the PUT is what stops the .strict() schema from
|
|
|
|
|
|
// 400-ing the whole settings save.
|
|
|
|
|
|
const displayKeys = settingsUi.slice(settingsUi.indexOf('const displayKeys = new Set(['));
|
|
|
|
|
|
const listed = displayKeys.slice(0, displayKeys.indexOf(']);'));
|
|
|
|
|
|
const stripped = settingsUi.slice(settingsUi.indexOf('const {', settingsUi.indexOf('async saveAppSettings()')));
|
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
|
expect(listed, `${key} must be a display key`).toContain(`'${key}'`);
|
|
|
|
|
|
expect(stripped.slice(0, stripped.indexOf('} = settings;')), `${key} must be stripped from the PUT`).toContain(
|
|
|
|
|
|
`${key}: _`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('applies the save to the live terminal', () => {
|
|
|
|
|
|
const save = settingsUi.slice(settingsUi.indexOf('async saveAppSettings()'));
|
|
|
|
|
|
expect(save.slice(0, save.indexOf('\n },'))).toContain('this.applyTerminalFontWeights?.(settings)');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|