mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
- Font size, family and weight changes refit Pane B AND tell its PTY. They used to reflow the xterm only, leaving the CLI wrapping at the old column count, the garbled-redraw class #464 fixed for the primary pane. - The resize frame reports the size the xterm actually holds, with no 40x10 floor (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, and is always re-sent on a fresh socket so it re-registers as a desktop viewer. - The server's {t:'zc'} geometry report is handled: a different column count is adopted, rows stay local, using the primary pane's own reconcilePtyGeometry verdict. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
/**
|
||
* @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 } };
|
||
}
|
||
|
||
function makeApp(
|
||
opts: {
|
||
teammates?: number;
|
||
terminal?: ReturnType<typeof fakeTerminal> | null;
|
||
splitPane?: { terminal: ReturnType<typeof fakeTerminal>; fit: () => void } | null;
|
||
} = {}
|
||
) {
|
||
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,
|
||
// 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,
|
||
// 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,
|
||
_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', () => {
|
||
it('reaches an open split pane: same weights, refit AND reported to its PTY', () => {
|
||
const splitTerminal = fakeTerminal();
|
||
// fit(), not localFit(): a weight change can move the cell size, and the
|
||
// split pane's PTY must hear about the new geometry like the primary's does.
|
||
const fit = vi.fn();
|
||
const { app } = makeApp({ splitPane: { terminal: splitTerminal, fit } });
|
||
|
||
(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(fit).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
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)');
|
||
});
|
||
});
|