From edeaa15986d634046f49c6b95d96d0aab77d19af Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 14 Sep 2026 14:09:13 +0200 Subject: [PATCH] feat(terminal): configurable normal and bold font weight (#403) 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 changes no colour, and xterm substitutes a bright colour for bold only when the foreground is a palette index 0-7, so the substitution never fires for default-foreground text. A family shipping only a regular and a bold face keeps that step small (measured on Consolas: glyph ink rises from 14.25% to 16.57%), and picking a different family does not help, because 400 stays 400 whatever the family. Lowering the NORMAL weight is the only way to widen the gap. Two per-device settings beside "Terminal font" in the Font group, each defaulting to xterm's own value for its slot, so an untouched install renders exactly as it did before. Both thread into the main terminal and the Agent Teams panes, and apply on save without a reload. The bundled face had to be unclamped in the same change or the settings would look broken on a stock install. fonts/jetbrains-mono-variable.woff2 carries a wght axis of 100 to 800, but styles.css declared the face `400 700`, and the descriptor is what the browser synthesizes from: at that range 100, 200 and 300 rendered identically to 400 and 800 identically to 700 (measured in headless Chromium, both directions). The two families ahead of it in the default stack, Fira Code and Cascadia Code, exist only if the user installed them, so for most installs "normal = 300" would have been a no-op. Declared `100 800`, every step is distinct: 61%, 77% and 90% of the ink at 400, and 800 adds ~14% over 700. Nothing in the stylesheets asks for a monospace weight outside 400-700, so widening it changes nothing that rendered before. Details that are easy to get wrong and are pinned by tests: - Each slot falls back to its OWN xterm default, so an unset bold weight can never inherit `normal` and become a visible change. - A live save refreshes both echo overlays. They cache terminal.options.fontWeight and paint it into their spans, so without it 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. - A live save reaches open Agent Teams panes, which read their options at construction, exactly as applyTerminalSkin() propagates its own. - A stored weight the picker does not list (a hand-set 350) is added to the select rather than dropped, so merely opening App Settings cannot reset it. - _awaitTerminalFont() is untouched. CharSizeService measures through the CSS `font` shorthand, which resets the weight, so the measured face is always the 400 one and a weighted descriptor would request nothing new. Verified end to end in a headless browser against a live server: the save reaches the running terminal with no reload, the settings PUT stays 200 (both keys are display keys and are stripped before it, since SettingsUpdateSchema is strict), the value survives a reload, and the painted terminal really changes weight with the bundled font (lit-pixel ink 0.83 / 0.95 / 1.00 / 1.13 / 1.21 at 100 / 300 / default / 700 / 800). Proposed and analysed by @irisitymichaelgrundberg in discussion #403. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 2 + src/web/public/constants.js | 50 +++++++ src/web/public/i18n.js | 6 + src/web/public/index.html | 36 +++++ src/web/public/panels-ui.js | 6 +- src/web/public/settings-ui.js | 43 +++++- src/web/public/styles.css | 11 +- src/web/public/terminal-ui.js | 57 +++++++- test/terminal-font-weight.test.ts | 225 ++++++++++++++++++++++++++++++ test/terminal-font.test.ts | 59 ++++++++ 10 files changed, 491 insertions(+), 4 deletions(-) create mode 100644 test/terminal-font-weight.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5603fe13..099beae2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -318,6 +318,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package) +**Terminal font weight** (`terminalFontWeight` / `terminalFontWeightBold`, per-device, default = xterm's own `normal`/`bold`): 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 0-7, so the substitution never fires there. A two-face family keeps that step small and 400 stays 400 whatever family is chosen, which is why the NORMAL slot is settable at all. `CodemanTerminalFont.resolveWeights()` (constants.js, pure) resolves both slots, each against **its own** xterm default, so an unset bold weight can never inherit `normal`. ⚠️ **The `@font-face` descriptor, not the file, is what the browser synthesizes from**: `fonts/jetbrains-mono-variable.woff2` carries a `wght` axis of 100-800, and while `styles.css` declared it `400 700` every weight below 400 rendered identically to 400 and 800 identically to 700 — measured — so the setting was a no-op for anyone without Fira Code or Cascadia Code installed, which is most installs. It is declared `100 800`; re-narrowing it silently guts the feature (`test/terminal-font-weight.test.ts` pins the range). ⚠️ A live save must reach **both echo overlays** (`refreshFont()` — they cache `terminal.options.fontWeight` and paint it into their spans, so typed characters otherwise keep the old weight, most visible on a phone) **and open Agent Teams panes** (they read their options at construction, exactly like `applyTerminalSkin()` propagates). ⚠️ `_awaitTerminalFont()` is deliberately untouched: `CharSizeService` measures through the CSS `font` shorthand, which RESETS the weight, so the measured face is always the 400 one and a weighted descriptor would ask for nothing new. + **Theme skins / branding / i18n**: `skin` selects a palette via `data-skin` on ``, applied by an **inline pre-paint script** in `index.html` reading `localStorage['codeman:skin']` to avoid a flash of wrong theme. ⚠️ A skin is **four things that must stay in sync**, and missing any one degrades silently: the `html[data-skin="…"]` token block in `styles.css`, the xterm ANSI palette in `terminal-ui.js`, the pre-paint allowlist, and the Settings picker (both in `index.html`). `test/skin-themes.test.ts` is the static guard. Light skins additionally need `color-scheme: light` and xterm `minimumContrastRatio: 4.5`, and `applyTerminalSkin()` must call the local-echo overlay's `refreshFont()` because it caches the terminal fg/bg. `displayName` changes user-facing browser branding only and must NEVER rename npm package, CLI, API, storage, CSS, or protocol identifiers. `language` (`en`/`zh-CN`) keeps English as the canonical source so live switching stays reversible. User display names flow through `textContent`/attribute APIs and the server title's HTML escaper, never `innerHTML`. → [architecture-invariants#theme-skins](docs/architecture-invariants.md#theme-skins) **Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity) diff --git a/src/web/public/constants.js b/src/web/public/constants.js index a9f677a3..be9780b0 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -709,6 +709,54 @@ function resolveTerminalFontFamily(custom) { return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`; } +/** + * xterm's own defaults for the two weight slots, one per slot. + * + * They are deliberately kept apart rather than collapsed into a single + * fallback: handing the bold slot `normal` (or the normal slot `bold`) would + * turn an unset setting into a visible change, which is exactly the thing this + * feature exists to make controllable. + */ +const TERMINAL_FONT_WEIGHT_DEFAULTS = { fontWeight: 'normal', fontWeightBold: 'bold' }; + +/** + * Resolve ONE weight slot against xterm's validation rules. + * + * xterm accepts a number in 1..1000, or one of its own keyword/numeric-string + * options, and silently falls back to the slot default for anything else + * (`OptionsService._sanitizeAndValidateOption`). Resolving here instead means a + * stored value the picker does not list (a hand-set 350) still reaches the + * terminal, while junk in localStorage never does. + */ +function resolveTerminalFontWeightSlot(value, fallback) { + if (value === 'normal' || value === 'bold') return value; + const numeric = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN; + if (!Number.isFinite(numeric) || numeric < 1 || numeric > 1000) return fallback; + return Math.round(numeric); +} + +/** + * Resolve both xterm weight slots from the per-device settings blob. + * + * 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, and xterm's bold-to-bright substitution only fires for palette + * indices 0-7, so it never applies to default-foreground text. A family that + * ships only a regular and a bold face keeps that step small, and 400 stays + * 400 whatever family is chosen — lowering the NORMAL weight is the only way + * to widen the gap. + */ +function resolveTerminalFontWeights(settings) { + const s = settings && typeof settings === 'object' ? settings : {}; + return { + fontWeight: resolveTerminalFontWeightSlot(s.terminalFontWeight, TERMINAL_FONT_WEIGHT_DEFAULTS.fontWeight), + fontWeightBold: resolveTerminalFontWeightSlot( + s.terminalFontWeightBold, + TERMINAL_FONT_WEIGHT_DEFAULTS.fontWeightBold + ), + }; +} + // --------------------------------------------------------------------------- // Auto Copy (copy-on-select). Pure decision, so every guard below is testable // without a terminal, a clipboard, or a browser. @@ -809,6 +857,8 @@ if (typeof window !== 'undefined') { window.CodemanTerminalFont = { DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK, resolve: resolveTerminalFontFamily, + WEIGHT_DEFAULTS: TERMINAL_FONT_WEIGHT_DEFAULTS, + resolveWeights: resolveTerminalFontWeights, }; } diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 4c47e7fa..23605d2f 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -335,6 +335,12 @@ 'Terminal font': '终端字体', 'Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.': '置于内置字体栈之前,回退字体(包括内置的 Nerd Font 图标)仍然生效。需已安装在本设备上。留空使用默认值。', + 'Normal font weight': '常规字重', + 'Weight for ordinary terminal text. Lowering it widens the step up to bold, which for a family shipping only a regular and a bold face is the only cue bold text carries. Needs a family with faces at that weight; the bundled font covers 100 to 800.': + '终端普通文本的字重。调低可拉大与粗体之间的差距;对于只提供常规和粗体两种字形的字体,这一差距是粗体文本唯一的视觉提示。需要字体具备该字重的字形,内置字体覆盖 100 至 800。', + 'Bold font weight': '粗体字重', + 'Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.': + '终端粗体文本的字重。仅当字体提供比其粗体更重的字形时才有意义。', 'Local Echo': '本地回显', 'CJK Input': '中日韩输入', 'Extended Keyboard Bar': '扩展键盘栏', diff --git a/src/web/public/index.html b/src/web/public/index.html index 5ddb00fa..8408c30b 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1704,6 +1704,42 @@ +
+
+ Normal font weight + Weight for ordinary terminal text. Lowering it widens the step up to bold, which for a family shipping only a regular and a bold face is the only cue bold text carries. Needs a family with faces at that weight; the bundled font covers 100 to 800. +
+ +
+
+
+ Bold font weight + Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face. +
+ +
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index b3b6fbf9..80e6ce83 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -2282,10 +2282,14 @@ Object.assign(CodemanApp.prototype, { return; } + const fontSettings = this.loadAppSettingsFromStorage?.() || {}; const terminal = new Terminal({ theme: { ...window.codemanCurrentXtermTheme() }, minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1, - fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily), + fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily), + // A pane opened after a weight change must match the main terminal; + // one open across the change is repainted by applyTerminalFontWeights(). + ...window.CodemanTerminalFont.resolveWeights(fontSettings), fontSize: 12, lineHeight: 1.2, cursorBlink: true, diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 2af5adf4..0633fc90 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -335,6 +335,32 @@ Object.assign(CodemanApp.prototype, { // App Settings Modal // ═══════════════════════════════════════════════════════════════ + /** + * Point one terminal-weight select at its stored value. + * + * A stored value the picker does not list (a hand-set 350, or a weight from a + * build whose options differ) is ADDED to the select rather than dropped: + * otherwise `select.value = '350'` silently selects nothing, the next save + * reads back '' and the setting resets itself just for having been opened. + * Empty means "use xterm's default for this slot". + */ + populateTerminalFontWeight(select, value) { + if (!select) return; + const stored = value === undefined || value === null ? '' : String(value).trim(); + if (stored && !Array.from(select.options).some((opt) => opt.value === stored)) { + const extra = document.createElement('option'); + extra.value = stored; + extra.textContent = `${stored} (custom)`; + select.appendChild(extra); + } + select.value = stored; + }, + + /** Read one terminal-weight select back. '' means default; the resolver in constants.js validates. */ + readTerminalFontWeight(select) { + return select?.value.trim() || ''; + }, + openAppSettings() { // Load current settings const settings = this.loadAppSettingsFromStorage(); @@ -411,6 +437,11 @@ Object.assign(CodemanApp.prototype, { // a way to read, so it is opt-in rather than a default anyone has to discover. document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true; document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || ''; + this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight); + this.populateTerminalFontWeight( + document.getElementById('appSettingsTerminalFontWeightBold'), + settings.terminalFontWeightBold + ); document.getElementById('appSettingsTerminalWheelLocal').checked = settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false; document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false; @@ -2091,6 +2122,10 @@ Object.assign(CodemanApp.prototype, { localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked, terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(), + terminalFontWeight: this.readTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight')), + terminalFontWeightBold: this.readTerminalFontWeight( + document.getElementById('appSettingsTerminalFontWeightBold') + ), terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked, cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked, webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked, @@ -2145,6 +2180,7 @@ Object.assign(CodemanApp.prototype, { this.saveAppSettingsToStorage(settings); this._updateLocalEchoState(); this.applyTerminalFontFamily?.(settings.terminalFontFamily); + this.applyTerminalFontWeights?.(settings); // A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback // marker so the next reload actually re-tries WebGL. Only the transition @@ -2302,6 +2338,11 @@ Object.assign(CodemanApp.prototype, { // Per-device by nature (the font must exist on the device) and absent // from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT. terminalFontFamily: _tff, + // Same two reasons: which weights a family can actually render is a + // property of the faces installed on THIS device, and neither key is + // declared in the .strict() schema. + terminalFontWeight: _tfw, + terminalFontWeightBold: _tfwb, // Per-device header/toolbar button toggles — client-only, and absent from // SettingsUpdateSchema (.strict()), so sending them would 400 the PUT. showSessionButton: _ssb, @@ -3076,7 +3117,7 @@ Object.assign(CodemanApp.prototype, { 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', - 'terminalFontFamily', + 'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold', 'language', 'terminalWheelLocalScrollback', 'autoCopySelection', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 49b26810..aedba597 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -9,11 +9,20 @@ font-weight: 400 800; src: url('fonts/manrope-variable.woff2') format('woff2'); } +/* The declared range is what the browser will synthesize from, NOT what the + file carries: the woff2 behind this has a `wght` axis of 100 to 800, and a + narrower descriptor clamps it — at `400 700`, requesting 100, 200 or 300 + rendered identically to 400 and 800 identically to 700. The terminal + font-weight settings would then be a no-op for anyone on the bundled face, + which is most installs (the two families ahead of it in the stack, Fira Code + and Cascadia Code, exist only if the user installed them). Nothing in the + stylesheets asks for a monospace weight outside 400-700, so widening it + changes nothing that renders today. */ @font-face { font-family: 'JetBrains Mono'; font-style: normal; font-display: swap; - font-weight: 400 700; + font-weight: 100 800; src: url('fonts/jetbrains-mono-variable.woff2') format('woff2'); } /* Icons-only per-glyph fallback for the terminal (Symbols Nerd Font Mono, MIT, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 4919d0b0..11b7ea54 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -248,9 +248,13 @@ Object.assign(CodemanApp.prototype, { const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK; this._destroyKeyCode229Recovery(); + const fontSettings = this.loadAppSettingsFromStorage?.() || {}; this.terminal = new Terminal({ theme: { ...window.codemanCurrentXtermTheme() }, - fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily), + fontFamily: window.CodemanTerminalFont.resolve(fontSettings.terminalFontFamily), + // Both weight slots, each falling back to xterm's own default for that + // slot, so an untouched install renders exactly as it always has. + ...window.CodemanTerminalFont.resolveWeights(fontSettings), // Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line) fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14, lineHeight: 1.2, @@ -4905,6 +4909,57 @@ Object.assign(CodemanApp.prototype, { this._predictiveEcho?.refreshFont(); }, + /** + * Apply the per-device terminal font WEIGHTS to every live xterm. + * + * Both slots move together because they are resolved together: passing a + * settings blob with neither key restores xterm's own `normal`/`bold`. + * + * Three things follow the option write and none of them is optional: + * + * - The echo overlays cache `terminal.options.fontWeight` and paint it into + * their spans, so without `refreshFont()` 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. + * - Agent Teams panes read these options at CONSTRUCTION, so a live save + * would otherwise leave an open pane at the old weight beside a repainted + * terminal. `applyTerminalSkin()` propagates for the same reason. + * - The refit is insurance. `CharSizeService` measures through the CSS + * `font` shorthand, which resets the weight, so the canvas path measures + * the 400 face at every setting — but `DomRenderer` styles its measure + * span with `span:not(.xterm-bold)`, where the normal weight really can + * move the cell. + */ + applyTerminalFontWeights(settings) { + const { fontWeight, fontWeightBold } = window.CodemanTerminalFont.resolveWeights(settings); + if (!this.terminal) return; + if (this.terminal.options.fontWeight === fontWeight && this.terminal.options.fontWeightBold === fontWeightBold) { + return; + } + this.terminal.options.fontWeight = fontWeight; + this.terminal.options.fontWeightBold = fontWeightBold; + // Same race as a live family change: the option write makes xterm + // re-measure immediately, against a face the browser may not have + // rasterized yet. Re-arm the wait and fit again once it settles; the fit + // below still runs, so the terminal is never left unfitted. + this._terminalFontReady = this._awaitTerminalFont().then(() => { + if (this.terminal?.options?.fontWeight === fontWeight) this.fitAddon?.fit(); + }); + this.fitAddon?.fit(); + this._localEchoOverlay?.refreshFont(); + this._predictiveEcho?.refreshFont(); + for (const [, entry] of this.teammateTerminals || []) { + if (!entry?.terminal) continue; + entry.terminal.options.fontWeight = fontWeight; + entry.terminal.options.fontWeightBold = fontWeightBold; + try { + entry.fitAddon?.fit(); + } catch { + /* pane not laid out yet — its own resize observer refits it */ + } + } + }, + loadFontSize() { const saved = localStorage.getItem('codeman-font-size'); if (saved) { diff --git a/test/terminal-font-weight.test.ts b/test/terminal-font-weight.test.ts new file mode 100644 index 00000000..14605ef7 --- /dev/null +++ b/test/terminal-font-weight.test.ts @@ -0,0 +1,225 @@ +/** + * @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 { + const FakeCodemanApp = function () {} as unknown as { prototype: Record }; + 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 = {}) { + return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } }; +} + +function makeApp(opts: { teammates?: number; terminal?: ReturnType | null } = {}) { + const fit = vi.fn(); + const teammateFits: ReturnType[] = []; + const teammateTerminals = new Map; 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, + _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('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(`