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(`