mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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.
@@ -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': '扩展键盘栏',
|
||||
|
||||
@@ -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 & rendering</h4></div>
|
||||
<div class="set-group-body">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user