Merge pull request #320 from comzine/feat/custom-terminal-font

feat: Nerd Font prompt icons out of the box + configurable terminal font
This commit is contained in:
Ark0N
2026-08-19 23:04:50 +02:00
committed by GitHub
10 changed files with 176 additions and 2 deletions
+39
View File
@@ -527,6 +527,41 @@ function sortSessionsByActivity(rows) {
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
}
// Terminal font stack — the single source for every xterm surface (the main
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
// @font-face in styles.css): browsers fall back PER GLYPH, so Nerd Font
// prompt icons (powerline segments, folder/git glyphs from p10k, starship,
// oh-my-posh) render even though the text fonts carry no private-use-area
// symbols — while all readable text keeps coming from the text fonts.
const TERMINAL_FONT_DEFAULT_STACK =
'"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, "Symbols Nerd Font Mono", monospace';
/**
* Resolve the xterm fontFamily from the per-device `terminalFontFamily`
* setting. A user-set family (or comma-separated list) is PREPENDED to the
* built-in stack, never a replacement — the symbols fallback and a final
* `monospace` must survive whatever the user types. Blank input yields the
* default. Unquoted names that need quoting for CSS (spaces, digits leading,
* etc.) are quoted; embedded quotes are stripped rather than escaped, since
* a font name cannot contain them anyway.
*/
function resolveTerminalFontFamily(custom) {
const raw = typeof custom === 'string' ? custom.trim() : '';
if (!raw) return TERMINAL_FONT_DEFAULT_STACK;
const families = raw
.split(',')
.map((f) => f.trim().replace(/^["']|["']$/g, '').replace(/["']/g, '').trim())
.filter(Boolean)
// Drop generic families the user may append — the default stack already
// ends in `monospace`, and a duplicate earlier entry would shadow the
// symbols fallback behind it.
.filter((f) => !/^(monospace|serif|sans-serif|system-ui)$/i.test(f))
.map((f) => (/^[A-Za-z][A-Za-z0-9-]*$/.test(f) ? f : `"${f}"`));
if (!families.length) return TERMINAL_FONT_DEFAULT_STACK;
return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`;
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -560,6 +595,10 @@ if (typeof window !== 'undefined') {
compare: compareSessionActivity,
sort: sortSessionsByActivity,
};
window.CodemanTerminalFont = {
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
resolve: resolveTerminalFontFamily,
};
}
// Scheduler API — prioritize terminal writes over background UI updates.
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Ryan L McIntyre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
+4
View File
@@ -326,6 +326,10 @@
// Input settings
Input: '输入',
Font: '字体',
'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 图标)仍然生效。需已安装在本设备上。留空使用默认值。',
'Local Echo': '本地回显',
'CJK Input': '中日韩输入',
'Extended Keyboard Bar': '扩展键盘栏',
+16
View File
@@ -24,6 +24,9 @@
<!-- Preload critical resources — lets browser discover these during HTML parse
instead of waiting until <script> tags at bottom-of-body are reached. -->
<link rel="preload" href="vendor/xterm.min.js" as="script">
<!-- Symbols font before the terminal first paints — a late-loading icon font
leaves tofu in xterm's glyph atlas until something forces a re-render. -->
<link rel="preload" href="fonts/symbols-nerd-font-mono.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="constants.js" as="script">
<link rel="preload" href="app.js" as="script">
<!-- Self-hosted xterm.js — eliminates CDN DNS/TLS latency (~100ms).
@@ -1624,6 +1627,19 @@
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Font</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row has-field" data-search="terminal font family nerd custom typeface">
<div class="set-row-text">
<span class="set-row-label">Terminal font</span>
<span class="set-row-desc">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.</span>
</div>
<input type="text" id="appSettingsTerminalFont" class="set-input" placeholder='e.g. JetBrainsMono Nerd Font'>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Scrolling &amp; rendering</h4></div>
<div class="set-group-body">
+1 -1
View File
@@ -2279,7 +2279,7 @@ Object.assign(CodemanApp.prototype, {
const terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
fontSize: 12,
lineHeight: 1.2,
cursorBlink: true,
+7
View File
@@ -381,6 +381,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
document.getElementById('appSettingsTerminalWheelLocal').checked =
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
@@ -2006,6 +2007,7 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
@@ -2052,6 +2054,7 @@ Object.assign(CodemanApp.prototype, {
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
// 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
@@ -2200,6 +2203,9 @@ Object.assign(CodemanApp.prototype, {
showFileViewerButton: _fvb,
webglRendererEnabled: _wgl,
terminalWheelLocalScrollback: _twls,
// Per-device by nature (the font must exist on the device) and absent
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
terminalFontFamily: _tff,
// Per-device header/toolbar button toggles — client-only, and absent from
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
showSessionButton: _ssb,
@@ -2874,6 +2880,7 @@ Object.assign(CodemanApp.prototype, {
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily',
'language',
'terminalWheelLocalScrollback',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
+13
View File
@@ -16,6 +16,19 @@
font-weight: 400 700;
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
}
/* Icons-only per-glyph fallback for the terminal (Symbols Nerd Font Mono, MIT,
fonts/LICENSE-nerd-fonts.txt). Sits BEHIND the text fonts in the xterm stack
(constants.js: TERMINAL_FONT_DEFAULT_STACK), so it only ever supplies the
private-use-area glyphs shell prompts draw (powerline, p10k/starship folder
and git icons) — text rendering is untouched. `block` display, not `swap`:
there is no fallback that CAN render these glyphs, so swapping in tofu first
would poison xterm's glyph atlas until the next re-render. */
@font-face {
font-family: 'Symbols Nerd Font Mono';
font-style: normal;
font-display: block;
src: url('fonts/symbols-nerd-font-mono.woff2') format('woff2');
}
:root {
/* Dark is the safe fallback. Light skins override this so native selects,
+17 -1
View File
@@ -225,7 +225,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
// 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,
@@ -4122,6 +4122,22 @@ Object.assign(CodemanApp.prototype, {
this._predictiveEcho?.refreshFont();
},
/**
* Apply the per-device `terminalFontFamily` setting to the live terminal.
* The custom family is resolved against the built-in stack (constants.js),
* so passing '' / undefined restores the default. Mirrors setFontSize():
* refit for the new cell metrics, then refresh the echo overlays' cached
* font so predictions keep landing on the right cells.
*/
applyTerminalFontFamily(custom) {
const resolved = window.CodemanTerminalFont.resolve(custom);
if (!this.terminal || this.terminal.options.fontFamily === resolved) return;
this.terminal.options.fontFamily = resolved;
this.fitAddon?.fit();
this._localEchoOverlay?.refreshFont();
this._predictiveEcho?.refreshFont();
},
loadFontSize() {
const saved = localStorage.getItem('codeman-font-size');
if (saved) {
+58
View File
@@ -0,0 +1,58 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
function loadFontHelper() {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
return (
context.window as {
CodemanTerminalFont: {
DEFAULT_STACK: string;
resolve: (custom?: unknown) => string;
};
}
).CodemanTerminalFont;
}
const font = loadFontHelper();
describe('CodemanTerminalFont', () => {
it('returns the default stack for empty or missing input', () => {
expect(font.resolve(undefined)).toBe(font.DEFAULT_STACK);
expect(font.resolve('')).toBe(font.DEFAULT_STACK);
expect(font.resolve(' ')).toBe(font.DEFAULT_STACK);
expect(font.resolve(42)).toBe(font.DEFAULT_STACK);
});
it('keeps the symbols fallback ahead of monospace in the default stack', () => {
const symbolsAt = font.DEFAULT_STACK.indexOf('"Symbols Nerd Font Mono"');
const monoAt = font.DEFAULT_STACK.lastIndexOf('monospace');
expect(symbolsAt).toBeGreaterThan(-1);
expect(monoAt).toBeGreaterThan(symbolsAt);
});
it('prepends a custom family and preserves the full default stack', () => {
expect(font.resolve('Menlo')).toBe(`Menlo, ${font.DEFAULT_STACK}`);
});
it('quotes names that need quoting for CSS', () => {
expect(font.resolve('JetBrainsMono Nerd Font')).toBe(`"JetBrainsMono Nerd Font", ${font.DEFAULT_STACK}`);
});
it('normalizes already-quoted input instead of double-quoting', () => {
expect(font.resolve('"JetBrainsMono Nerd Font"')).toBe(`"JetBrainsMono Nerd Font", ${font.DEFAULT_STACK}`);
expect(font.resolve("'Iosevka Term'")).toBe(`"Iosevka Term", ${font.DEFAULT_STACK}`);
});
it('accepts a comma-separated list', () => {
expect(font.resolve('Iosevka, MesloLGS NF')).toBe(`Iosevka, "MesloLGS NF", ${font.DEFAULT_STACK}`);
});
it('drops generic families so they cannot shadow the symbols fallback', () => {
expect(font.resolve('monospace')).toBe(font.DEFAULT_STACK);
expect(font.resolve('Hack, monospace')).toBe(`Hack, ${font.DEFAULT_STACK}`);
});
});