feat(terminal): configurable normal and bold font weight (#403) - #417
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
ESC[1mand changes no colour (confirmed against a live tmux pane captured with escapes intact).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:
normalboldBoth 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 inheritnormal. 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.woff2carries awghtaxis of 100 to 800 (read out of itsfvartable), butstyles.cssdeclared the facefont-weight: 400 700. The@font-facedescriptor, not the file, is what the browser synthesizes from: at400 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:400 700(before)100 800(after)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: 800rules are.welcome-titleand.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.
terminal.options.fontWeightand 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.applyTerminalSkin()propagates for the same reason.select.value = '350'selects nothing, the next save reads back empty, and merely opening App Settings resets the setting.displayKeys(so one device cannot overwrite another's value) and both are stripped before the settings PUT (soSettingsUpdateSchema, which is.strict(), does not 400 the whole save). Same two reasons asterminalFontFamily._awaitTerminalFont()is untouched.CharSizeServicemeasures the cell with${fontSize}px ${fontFamily}, and the CSSfontshorthand resets the weight, so the measurement always uses the 400 face. A weighted descriptor would request nothing new. The re-arm and refit insideapplyTerminalFontWeightsstay as local insurance, becauseDomRendererstyles its measure span throughspan:not(.xterm-bold), where the normal weight really can move the cell.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-syntaxandnpm run check:public-assetsall pass.New coverage: 8 resolver cases in
test/terminal-font.test.tsand 11 intest/terminal-font-weight.test.ts(live apply, overlay refresh, teammate propagation, the no-op guard, the@font-facerange, and the per-device plumbing on both halves).Verified end to end in a headless browser against a live isolated server:
normal/bold.PUT /api/settingsreturns 200 every time, so the strip works.normalto300on 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.