mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 23:19:43 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user