Skip to content

feat(terminal): configurable normal and bold font weight (#403) - #417

Merged
Ark0N merged 1 commit into
masterfrom
feat/terminal-font-weight
Sep 14, 2026
Merged

Ark0N merged 1 commit into
masterfrom
feat/terminal-font-weight

Conversation

@Ark0N

@Ark0N Ark0N commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Implements the shape agreed in discussion #403, proposed and analysed by @irisitymichaelgrundberg.

The problem

Bold text in an agent CLI is often indistinguishable from ordinary text, and the reason is that it carries exactly one cue.

  • Claude Code marks its markdown bold with a bare ESC[1m and changes no colour (confirmed against a live tmux pane captured with escapes intact).
  • xterm substitutes a bright colour for bold text only when the foreground is a palette index 0 through 7, so the substitution never fires for text sitting on the theme's default foreground.

That leaves the weight step, and a family shipping only a regular and a bold face keeps that step small. Measured on Consolas (Firefox on Windows, 150% scaling, dark skin) glyph ink coverage rises from 14.25% at normal to 16.57% at bold: at 14px a bold heading reads as body text.

Changing the family does not fix it, because 400 stays 400 whatever the family. Lowering the normal weight is the only way to widen the gap, and nothing exposed it.

What this adds

Two per-device settings beside the existing Terminal font field in App Settings → Terminal → Font:

  • Normal font weight, defaulting to xterm's normal
  • Bold font weight, defaulting to xterm's bold

Both are selects offering Default plus 100 through 900. Setting normal to 300 while bold stays at its default turns one small step into an obvious one, and makes ordinary text lighter as well.

CodemanTerminalFont.resolveWeights() (constants.js, pure) resolves both slots, each against its own xterm default, so an unset bold weight can never inherit normal. An untouched install renders exactly as it did before.

The bundled font had to be unclamped in the same change

Otherwise the setting looks broken on a stock install.

fonts/jetbrains-mono-variable.woff2 carries a wght axis of 100 to 800 (read out of its fvar table), but styles.css declared the face font-weight: 400 700. The @font-face descriptor, not the file, is what the browser synthesizes from: at 400 700, requesting 100, 200 or 300 rendered identically to 400, and 800 identically to 700. Measured in headless Chromium in both directions, against the real page:

declared range 100 == 400 800 == 700
400 700 (before) yes yes
100 800 (after) no no

The default stack is "Fira Code", "Cascadia Code", "JetBrains Mono", … and the first two exist only if the user installed them, so for most installs "normal = 300" would have been a silent no-op that reads as a broken setting rather than a font limit. With the descriptor widened, every step is distinct: 100, 200 and 300 land at roughly 61%, 77% and 90% of the ink at 400, and 800 adds about 14% over 700.

Nothing in the stylesheets asks for a monospace weight outside 400 to 700 (the only font-weight: 800 rules are .welcome-title and .mobile-overview-brand, both on the UI font), so widening it changes nothing that rendered before.

Details that are easy to get wrong

Each is pinned by a test.

  • Both echo overlays are refreshed on a live save. They cache terminal.options.fontWeight and paint it into their spans, so without it the characters you are typing keep the old weight while the rest of the screen changes. Most visible on a phone, where local echo is on by default.
  • Open Agent Teams panes are repainted. They read their options at construction, so a live save would otherwise leave a pane at the old weight beside a repainted terminal. applyTerminalSkin() propagates for the same reason.
  • A stored weight the picker does not list survives. A hand-set 350 is added to the select rather than dropped; without that, select.value = '350' selects nothing, the next save reads back empty, and merely opening App Settings resets the setting.
  • Per-device, which is two separate decisions. Both keys are in displayKeys (so one device cannot overwrite another's value) and both are stripped before the settings PUT (so SettingsUpdateSchema, which is .strict(), does not 400 the whole save). Same two reasons as terminalFontFamily.
  • _awaitTerminalFont() is untouched. CharSizeService measures the cell with ${fontSize}px ${fontFamily}, and the CSS font shorthand resets the weight, so the measurement always uses the 400 face. A weighted descriptor would request nothing new. The re-arm and refit inside applyTerminalFontWeights stay as local insurance, because DomRenderer styles its measure span through span:not(.xterm-bold), where the normal weight really can move the cell.
  • An unchanged save is a no-op, so opening and closing App Settings does not churn the terminal.

Testing

npm test (the CI gate): 361 files, 6901 tests, 12 skipped, all passing. npm run typecheck, npm run lint, npm run format:check, npm run check:frontend-syntax and npm run check:public-assets all pass.

New coverage: 8 resolver cases in test/terminal-font.test.ts and 11 in test/terminal-font-weight.test.ts (live apply, overlay refresh, teammate propagation, the no-op guard, the @font-face range, and the per-device plumbing on both halves).

Verified end to end in a headless browser against a live isolated server:

  1. A fresh install sits on xterm's normal/bold.
  2. Saving 300/800 reaches the running terminal with no reload.
  3. PUT /api/settings returns 200 every time, so the strip works.
  4. The value survives a reload and repopulates the picker.
  5. A hand-set 350 reaches the terminal, shows in the picker and survives a save.
  6. Clearing both restores xterm's defaults live.
  7. The painted terminal really changes weight with the bundled font: lit-pixel ink 0.83 / 0.95 / 1.00 / 1.13 / 1.21 at weights 100 / 300 / default / 700 / 800, thousands of pixels differing at each step.
  8. The local-echo overlay's cached weight moves from normal to 300 on the live save, which is exactly what would be stale without the refresh.

Not in this PR

The same live-apply gap exists today for terminalFontFamily, which only updates the main terminal and not open Agent Teams panes. Left alone deliberately rather than folded in here.

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>
@Ark0N
Ark0N merged commit 47e92e0 into master Sep 14, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Sep 14, 2026
`populateTerminalFontWeight` adds an option for a stored weight the
picker does not list, so a hand-set 350 survives being looked at. It
never removed the one a previous open added, and App Settings is opened
again and again: a device that held 350, then 200, then 400 ended up
with a picker offering all three, none of which was the stored value.

Each custom entry is marked and cleared at the top of the next populate,
before the add (clearing after it would take out the entry just made).
Returning to a listed weight leaves the picker with no custom entries at
all.

Found by a DeepSeek Harness review of #417 running on the local RTX 5090,
which named the file and the line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants