mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Merge pull request #417 from Ark0N/feat/terminal-font-weight
feat(terminal): configurable normal and bold font weight (#403)
This commit is contained in:
@@ -319,6 +319,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 `<html>`, 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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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': '扩展键盘栏',
|
||||
|
||||
@@ -1704,6 +1704,42 @@
|
||||
</div>
|
||||
<input type="text" id="appSettingsTerminalFont" class="set-input" placeholder='e.g. JetBrainsMono Nerd Font'>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="terminal font weight normal regular light thin bold contrast">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Normal font weight</span>
|
||||
<span class="set-row-desc">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.</span>
|
||||
</div>
|
||||
<select id="appSettingsTerminalFontWeight" class="set-select">
|
||||
<option value="">Default (normal)</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="300">300</option>
|
||||
<option value="400">400</option>
|
||||
<option value="500">500</option>
|
||||
<option value="600">600</option>
|
||||
<option value="700">700</option>
|
||||
<option value="800">800</option>
|
||||
<option value="900">900</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="terminal bold font weight heavy black emphasis">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Bold font weight</span>
|
||||
<span class="set-row-desc">Weight for bold terminal text. Only useful with a family carrying something heavier than its bold face.</span>
|
||||
</div>
|
||||
<select id="appSettingsTerminalFontWeightBold" class="set-select">
|
||||
<option value="">Default (bold)</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="300">300</option>
|
||||
<option value="400">400</option>
|
||||
<option value="500">500</option>
|
||||
<option value="600">600</option>
|
||||
<option value="700">700</option>
|
||||
<option value="800">800</option>
|
||||
<option value="900">900</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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 } = {}) {
|
||||
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,
|
||||
_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(`<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)');
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ function loadFontHelper() {
|
||||
CodemanTerminalFont: {
|
||||
DEFAULT_STACK: string;
|
||||
resolve: (custom?: unknown) => string;
|
||||
WEIGHT_DEFAULTS: { fontWeight: string; fontWeightBold: string };
|
||||
resolveWeights: (settings?: unknown) => { fontWeight: string | number; fontWeightBold: string | number };
|
||||
};
|
||||
}
|
||||
).CodemanTerminalFont;
|
||||
@@ -56,3 +58,60 @@ describe('CodemanTerminalFont', () => {
|
||||
expect(font.resolve('Hack, monospace')).toBe(`Hack, ${font.DEFAULT_STACK}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanTerminalFont.resolveWeights', () => {
|
||||
const DEFAULTS = { fontWeight: 'normal', fontWeightBold: 'bold' };
|
||||
|
||||
it("leaves an untouched install on xterm's own defaults", () => {
|
||||
// The whole feature has to be invisible until someone asks for it.
|
||||
expect(font.resolveWeights(undefined)).toEqual(DEFAULTS);
|
||||
expect(font.resolveWeights({})).toEqual(DEFAULTS);
|
||||
expect(font.resolveWeights('nonsense')).toEqual(DEFAULTS);
|
||||
expect(font.WEIGHT_DEFAULTS).toEqual(DEFAULTS);
|
||||
});
|
||||
|
||||
it('resolves each slot independently', () => {
|
||||
expect(font.resolveWeights({ terminalFontWeight: 300 })).toEqual({ fontWeight: 300, fontWeightBold: 'bold' });
|
||||
expect(font.resolveWeights({ terminalFontWeightBold: 800 })).toEqual({ fontWeight: 'normal', fontWeightBold: 800 });
|
||||
expect(font.resolveWeights({ terminalFontWeight: 300, terminalFontWeightBold: 800 })).toEqual({
|
||||
fontWeight: 300,
|
||||
fontWeightBold: 800,
|
||||
});
|
||||
});
|
||||
|
||||
it('never hands one slot the other slot default', () => {
|
||||
// A shared fallback would turn an unset bold weight into a visible change.
|
||||
for (const bad of [null, '', ' ', 'heavy', NaN, {}, [], true]) {
|
||||
expect(font.resolveWeights({ terminalFontWeight: bad, terminalFontWeightBold: bad })).toEqual(DEFAULTS);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts the string values the select stores', () => {
|
||||
expect(font.resolveWeights({ terminalFontWeight: '300', terminalFontWeightBold: '900' })).toEqual({
|
||||
fontWeight: 300,
|
||||
fontWeightBold: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a hand-set weight the picker does not offer', () => {
|
||||
expect(font.resolveWeights({ terminalFontWeight: '350' }).fontWeight).toBe(350);
|
||||
});
|
||||
|
||||
it("passes through xterm's own keywords unchanged", () => {
|
||||
expect(font.resolveWeights({ terminalFontWeight: 'bold', terminalFontWeightBold: 'normal' })).toEqual({
|
||||
fontWeight: 'bold',
|
||||
fontWeightBold: 'normal',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects what xterm would reject, rather than letting it silently reset the slot', () => {
|
||||
// OptionsService accepts a number in 1..1000 and falls back otherwise, so
|
||||
// anything outside that range must resolve to the default here instead of
|
||||
// reaching the terminal and being swapped out underneath the setting.
|
||||
expect(font.resolveWeights({ terminalFontWeight: 0 }).fontWeight).toBe('normal');
|
||||
expect(font.resolveWeights({ terminalFontWeight: -400 }).fontWeight).toBe('normal');
|
||||
expect(font.resolveWeights({ terminalFontWeight: 1001 }).fontWeight).toBe('normal');
|
||||
expect(font.resolveWeights({ terminalFontWeight: 1000 }).fontWeight).toBe(1000);
|
||||
expect(font.resolveWeights({ terminalFontWeight: 1 }).fontWeight).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user