RFD D57: Capability-Aware Terminal Theming
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-06-02
- Extends: RFD 084, RFD 048
Summary
Introduce a styling system split into two layers: a frontend-agnostic semantic styling layer (a Style model, a base16 Palette, named element scopes, and a theme) living in a neutral jp_style crate, and a terminal-specific rendering layer owned by jp_printer. The terminal sink adapts resolved styling to the terminal it is writing to: downgrading 24-bit color to 256 or 16, selecting a light or dark default theme, and stripping color and unsafe control sequences for non-terminal channels and NO_COLOR. This fixes unreadable inline code on light terminals and garbled syntax highlighting on non-truecolor terminals (e.g. Apple Terminal), while keeping the styling model reusable by future web or TUI frontends.
Motivation
JP emits 24-bit ANSI color unconditionally and assumes a dark terminal. Two concrete, reported failures follow:
Inline code is unreadable on a light terminal.
format_codeinjp_mdsets a theme-derived background but never a foreground, so the code text falls back to the terminal's default foreground. On a white-background terminal that is black text on the darkgruvbox-darkbackground. The snapshot test today encodes exactly this:Hello \x1b[48;2;34;34;34mWorld\x1b[49m!.Syntax-highlighted tool output is garbled on non-truecolor terminals. Apple Terminal sets
TERM=xterm-256colorand noCOLORTERM, and does not support 24-bit color. A\x1b[48;2;r;g;bsequence is misparsed (everything after2is read as separate SGR parameters), so a syntax-highlighted file rendered byfs_create_file, one truecolor pair per token, turns to noise.
The shared root cause is structural: color is emitted as final bytes from many places (syntect's as_24_bit_terminal_escaped, theme_bg, theme_blockquote_fg, and assorted jp_cli chrome), and no single layer knows the terminal's capabilities. There is nowhere to put the fix. If we do nothing, every new styled element repeats the same two bugs, and JP stays unusable on a large class of terminals.
Design
Layering
Two layers, in separate homes:
- Semantic styling (frontend-agnostic), in a new
jp_stylecrate.Style(foreground, background, intensity, italic, underline, strikethrough), thePaletteand its base16 roles, the theme, the scope taxonomy (markdown.heading.h1,tool_call.function_name, ...), and stylesheet resolution. None of this is terminal-specific: a web frontend rendersStyleas CSS, a TUI as widget styles.jp_styleis pure and depends onanstyleonly for itsColorrepresentation.jp_configholds the config shapes and converts intojp_style;jp_mdandjp_printerdepend onjp_style. - Rendering target (terminal-specific), in
jp_term/jp_printer. TheColorProfile, the truecolor-to-256-to-16 downgrade, SGR byte construction, and the escape adapter.
The terminal is one frontend sink. The general pattern, not just this instance: logical Style/scope is the interchange, and each frontend has exactly one sink that turns it into target-native output. A future web or TUI frontend brings its own sink and reuses jp_style unchanged. This RFD builds the terminal sink and jp_style; it does not build the generic abstraction (see Non-Goals). jp_md's terminal output remains terminal-specific for D57: a future frontend renders the same stylesheet through its own serializer rather than reusing jp_md's SGR strings.
The model
ColorProfilecarries adepth(Truecolor | Ansi256 | Ansi16 | None) and ascheme: Option<Scheme>whereSchemeisLight | Dark.Nonemeans the scheme could not be determined (noCOLORFGBG, no OSC 11 answer, or the query was skipped); the readability rule depends on this. Depth and scheme describe the terminal program and are shared across its handles; whether a given handle emits color at all is decided per output target (is that handle a TTY?).Paletteis a small set of semantic color roles using the base16 vocabulary (base00..base0F:base00background,base05default foreground,base08red/error,base0Dblue/function, ...). A theme name resolves to a{ palette, syntect theme }pair: the palette colors chrome and markdown elements, the syntect theme colors code blocks. JP ships curated light and dark default themes; the exhaustive set of built-in names is an implementation detail.Scopestylesheet. Every styled element is named by an open-set string path. The default stylesheet maps each scope to a palette role (not a raw color), so switching the theme reassigns every scope for free. Users override per scope, with either a role reference or a literal color (see Configuration surface).
The terminal sink
Logical Style/scope is the interchange; the terminal sink is the only place that turns it into capability-adapted bytes. Two producers feed it:
jp_mdresolves markdown scopes against the shared stylesheet and palette and emits canonical truecolor strings. It keeps string output and stays usable as a library; the sink does not know markdown scopes.- Chrome hands the sink a logical
Style(or a chrome scope the sink resolves).
The adaptation lives in the per-sink writer, not in a single print method. Many call sites do not call Printer::print; they obtain an out_writer / err_writer handle and write to it. The policy therefore lives in the per-sink adapter those handles are already wrapped in (the AnsiStripper/Sink layer introduced in e2db8c90), so print and borrowed writers get identical treatment with no caller awareness. The adapter is a per-sink state machine with a persistent vte::Parser, not a per-write string transform, so escape sequences split across write calls (and the per-character typewriter path) are handled correctly. Tests cover split CSI sequences and typewriter output.
Escape and color policy
The policy is keyed on sequence category and target, never on the source of the bytes. This is what makes it work uniformly through borrowed writers without threading provenance: the sink cannot tell JP-generated bytes from relayed bytes, so it does not try. For every write:
| Sequence | Styled TTY | Non-styled (non-TTY / structured) |
|---|---|---|
| SGR color (fg/bg) | adapt to depth; dropped if color is off | strip |
| SGR attributes (bold/italic/underline/strike) | keep | strip |
| OSC 8 hyperlink (inline) | keep | strip |
| Out-of-band cursor/erase control | strip from content; JP emits via a trusted control path | strip |
| Dangerous OSC (52 clipboard, 0/1/2 title), other | strip | strip |
Two consequences:
- Color off is not the same as styling off.
NO_COLOR(anddepth = None) drops foreground/background color but keeps text attributes, per the NO_COLOR convention. Stripping all SGR happens only on a non-styled channel (non-TTY or structured/JSON output, which always runs non-styled regardless of color env vars). - Out-of-band control needs a trusted path. JP's own cursor/erase usage (progress lines,
\r\x1b[K) is emitted through a dedicated control method that bypasses the strip; anything written as normal content is stripped. The failure mode is therefore safe: forget the trusted path and you lose a progress redraw, never leak a relayed escape. There is no per-caller flag and no "sanitize first" rule.
Deliberate tradeoff: OSC 8 hyperlinks are allowed inline by category, so a tool's output can emit a hyperlink. This is acceptable (the URL is not auto-followed, the link text is visible) and avoids reintroducing source tracking. A complete trust-boundary policy for tool-emitted terminal control belongs with RFD 075; D57 only sets the default sink behavior.
Readability rule
A background-bearing element must contrast with whatever foreground is shown. JP resolves this on ColorProfile.scheme:
Some(Light | Dark): honor the user's terminal foreground and set only the scheme-matched background (e.g.inline_code.body.bg = base01). A light theme yields a light code background that the user's dark text reads on; JP does not override a foreground it cannot improve on.None(undetermined): set a self-contained pair (fg = base05,bg = base01) so the element is legible without depending on the terminal's foreground.
So inline code never renders as background-only-with-inherited-foreground when the scheme is unknown (the cause of bug 1), and when the scheme is known it honors the user's foreground. A user override that sets bg without fg is accepted as an explicit escape hatch and is not auto-completed.
Configuration surface
style is redesigned around a node-major layout. Each presentation node owns its behavior and its visual elements; two global sections hold the cross-cutting concerns.
# Global: the color source (shared by every frontend)
[style.theme]
default = "auto" # auto picks dark/light by detected scheme, or a name
dark = "gruvbox-dark"
light = "gruvbox-light"
[style.theme.palettes.my-dark] # optional custom base16 palette
base00 = "#282828"
base05 = "#ebdbb2"
# Terminal rendering target (capability overrides only)
[style.terminal]
color_depth = "auto" # auto | truecolor | 256 | 16 | none
background = "auto" # auto | light | dark
# Per node: behavior and visual elements together
[style.markdown]
wrap_width = 80
[style.markdown.elements.heading.h1]
fg = "base08" # see the value grammar below
intensity = "bold"
[style.markdown.elements.inline_code.body]
bg = "base01"
[style.tool_call]
parameters = "function_call"
[style.tool_call.elements.function_name]
fg = "base0D"
intensity = "bold"Rules:
fg/bgvalue grammar, consistent with RFD 084: a palette role ("base0D"), a hex literal ("#fb4934"), an ANSI-256 index (244),"default"(reset the channel to the terminal default, SGR 39/49), or"inherit"(suppress theme/renderer defaults without resetting, so the surrounding context shows through)."inherit"is how the stylesheet expresses the readability rule's "honor the user's foreground."- Visual styling for every node lives under
style.<node>.elements.*, generalizing RFD 084'sstyle.markdown.elements.*. Behavioral knobs stay on the node (style.markdown.wrap_width,style.tool_call.parameters). - Theme is global. It moves from
style.markdown.themetostyle.theme, because post-D57 it drives chrome and tool-call colors, not only markdown.default = "auto"is what makes the absence of an explicit choice trigger scheme-based selection;dark/lightname the theme used for each scheme and may reference a built-in or a custom palette.style.inline_codefolds intostyle.markdown.elements.inline_code. style.terminalholds only rendering-target capability overrides, so it sits as a sibling to any future frontend's target section rather than being a catch-all.
This is a deliberate breaking change to the style config, accepted as part of accepting this RFD. Old keys that no longer exist will not be silently ignored (config structs deny unknown fields), so the rename is handled explicitly: RFD 084's deprecation-alias phase for style.inline_code.background is dropped in favor of this clean rename, and the implementation maps the known old keys during load. D57 and RFD 084 share this single target shape.
Data flow
jp_md --> markdown scopes resolved to truecolor -+
chrome --> logical Style / chrome scope +--> per-sink adapter --> bytes
tool/raw --> relayed bytes -+The per-sink adapter (behind both Printer::print and the borrowed out_writer/err_writer handles): resolves chrome scopes, adapts each color to ColorProfile.depth, applies the category-based escape policy, and gates on the target (styled vs non-styled).
Detection and startup order
Detection lives in jp_term and produces a ColorProfile. Depth and scheme are terminal-wide; whether color is emitted on a given handle is per PrintTarget (Out, Err, Tty), based on that handle's own TTY state. So jp query > file still colors chrome on a TTY stderr, and the structured channel always runs non-styled.
This is a deliberate amendment to RFD 048: the serialization format (text/json) still resolves once from stdout per RFD 048, but the color decision is now per-target rather than following the global format. In --format json, both streams emit NDJSON and are never styled, regardless of color env vars or TTY state.
Because style.terminal.* has the highest precedence, the ColorProfile is resolved in jp_cli after config loads (merging config overrides with jp_term detection) and installed into the sink before any command rendering begins. Output emitted before that point (final error printing, bootstrap messages already outside the configured Printer) stays un-themed; those direct writes are out of scope (see The terminal sink).
Precedence:
- Emit color on a target: explicit config ->
NO_COLOR(off) ->CLICOLOR_FORCE(on, even when not a TTY) -> handle is a TTY (on) -> off. - Depth (terminal-wide): explicit config ->
COLORTERMtruecolor ->TERM*-256color-> colorfulTERM(16). - Scheme (terminal-wide): explicit config ->
COLORFGBG-> best-effort OSC 11 query ->None.
CLICOLOR_FORCE precedes the TTY check, so it can force color into a pipe; NO_COLOR still wins over it. Apple Terminal needs no special-casing: no COLORTERM plus TERM=xterm-256color lands it on Ansi256.
Reusing the ecosystem
The mechanical substrate is largely already in Cargo.lock via the anstyle family (pulled through clap), all MIT/Apache and already audited:
anstyleis the color modeljp_stylewraps, so the rest composes with no adapter code. This amends RFD 084, which currently defines a bespokeColorenum; the enum maps 1:1.anstyle-queryprovides theCOLORTERM/TERM/NO_COLOR/CLICOLORheuristics.anstyle-parse/vteprovide the SGR/escape parsing for the per-sink adapter.
Two small additions: anstyle-lossy (MIT/Apache, const fn truecolor-to-256-to-16 math) and termbg (light/dark detection). The base16 format is parsed in-house (a few dozen lines of serde over 16 hex strings); we deliberately do not pull tinted-builder (GPL-3.0) or ansi_colours (LGPL).
Drawbacks
- The scope taxonomy is a public contract. Once a user writes
tool_call.function_name.fg = "...", that name is fixed forever (Hyrum's Law). The taxonomy must grow deliberately, not churn. - The per-sink adapter reparses output to quantize. Adapting truecolor to a lower tier means parsing the stream per write. Mitigated by a memoized RGB-to-index cache keyed on the active
ColorProfile. - It amends RFD 084 and RFD 048 while both interact. 084's "SGR construction stays in
jp_md" narrows to "jp_mdresolves markdown scopes to canonical truecolor; the terminal sink owns tier adaptation," and 084's deprecation-alias phase is dropped in favor of the cleanstylerename. 048's global color behavior becomes per-target. The RFDs must land coherently. - Breaking the
styleconfig resets users' existing style customizations on upgrade and requires the explicit key migration noted above. Accepted as part of this RFD. - Curated per-tier light/dark default themes are real work, distinct from auto-downgrading an arbitrary theme.
Alternatives
- Set the inline-code foreground from the theme unconditionally. Rejected as the default: it overrides a foreground the user chose even when the scheme is correctly detected. Adopted only as the unknown-scheme fallback.
- Tag each print with a
PrintOrigin(JP chrome / markdown / tool / ...). Rejected: it pushes a per-call-site classification onto everyPrintercaller, inviting "you used it wrong" bugs. The category-based sink policy needs no provenance and keeps callers unaware. - Sanitize tool output at the call site before printing. Rejected for the same reason: it is caller awareness by another name. Safe-by-default in the sink is the only approach that keeps borrowed-writer callers unaware.
- Downgrade at each emission seam instead of in the sink. Rejected: color is emitted from the 084 stylesheet, the syntect path, and
jp_clichrome; a single sink covers all three and is the natural per-frontend boundary. - Default to 256 or base16 always. Rejected: throws away color quality on terminals that do support truecolor.
- Aspect-major config (
style.terminal.elements.<node>). Rejected: splits a node's config across two trees and contradicts RFD 084's shippedstyle.markdown.elements.*. - Adopt
termprofileas the all-in-one engine. Rejected for now: it pulls the heavypalettedependency, whileanstyle-query+anstyle-lossycover the same ground. Worth reading as a reference.
Non-Goals
- Building the web/TUI frontends or a generic frontend/sink abstraction. D57 ships the terminal sink and
jp_style. The generic abstraction is introduced when a second frontend exists; defining it now, with one implementation, would be speculative. - A full chrome scope taxonomy. This RFD ships
markdown.*(via RFD 084) andtool_call.*scopes; other chrome comes in follow-on RFDs against the same engine. - Palette generation / LAB interpolation (the base16-to-256-cube technique from the color256 writeup). Curated defaults need no LAB.
- Converting behavioral style sections onto the
Styletype. Adding anelementstree to a node does not change its behavioral config (hidden,parameters, reasoning display mode). - Replacing the
syntecttheme. Code blocks stay themed bysyntect; only their emitted colors flow through the same depth downgrade. - Perfect scheme detection. Best-effort plus a config override is the contract;
Noneis a first-class outcome. - A complete tool-emitted-escape trust policy. D57 sets the default sink behavior; the fuller policy is RFD 075's.
Risks and Open Questions
- Unknown-scheme fallback. With
scheme: Option<Scheme>, the readability rule has an explicitNonepath that picks a self-contained pair, so the legibility risk is removed. The residual question is the exact default pair and keeping the set of background-bearing default scopes small. - Scheme query reliability.
termbg/OSC 11 put the terminal into raw mode and read a reply. This runs once at startup, before any background work or streaming, so it is isolated. The only requirement is a short timeout with a clean fallback toNone, and skipping the query when stdout is not a TTY. - Cache invalidation. The memoized RGB-to-index cache must key on the active
ColorProfileso a mid-session change does not serve stale colors. anstyleversion coupling.jp_stylewrappinganstyleties us to its (stable, clap-maintained) API; acceptable given it is already a transitive dependency.
Implementation Plan
Phase 1: ColorProfile and the terminal sink
- Add
ColorProfile { depth, scheme: Option<Scheme> }and detection injp_term(anstyle-query+TERM;schemestubbed toNonefor now). Resolve the profile injp_cliafter config loads and install it into the sink before rendering, with per-PrintTargetcolor gating. - Extend the per-sink adapter (the
AnsiStripper/Sinklayer, persistentvte::Parser) to adapt SGR depth viaanstyle-lossy, apply the category-based escape policy, and run non-styled channels at strip-all. Add the trusted out-of-band control path for JP's progress/cursor sequences. The adapter covers bothPrinter::printand borrowedout_writer/err_writerhandles. Tests cover split CSI sequences and typewriter output. jp_mdand chrome emit canonical truecolor; the sink adapts on the way out. Addanstyle-lossy.- Fixes bug (2) (garbled tool output on non-truecolor terminals). The only new config is
style.terminal.color_depth.
Phase 2: jp_style, themes, and scheme selection
- Add the
jp_stylecrate (Style,Palette,Scope, stylesheet resolution, wrappinganstyle::Color), an in-house base16 loader, and curated light/dark default themes. Alignjp_md's logicalColorwithanstyle(amends RFD 084). - Add scheme detection (
termbg+COLORFGBG) producingOption<Scheme>, and scheme-driven default theme selection. Apply the hybrid readability rule. - Move
themeto the globalstyle.themewithdefault = "auto". - Fixes bug (1) (inline-code readability). Depends on Phase 1.
Phase 3: Node-major config surface and chrome scopes
- Land the node-major
stylelayout: globalstyle.themeandstyle.terminal, per-nodestyle.<node>.elements.*. Land RFD 084'sstyle.markdown.elements.*on this engine (084 amended to drop its alias phase), and addtool_call.*scopes. Implement thefg/bggrammar and the theme resolution model (name -> { palette, syntect }). - This is a breaking change to
style; the load path maps the known old keys and documents the new shape. The full scope taxonomy beyond markdown + tool_call is deferred to follow-on RFDs. Depends on Phase 2.
References
- RFD 004: the custom
jp_mdterminal renderer this builds on. - RFD 048: the four-channel output model. D57 reuses its per-target channels and amends its global color behavior to be per-target.
- RFD 075: tool sandbox and access policy. D57 sets the default sink sanitization of tool-emitted escapes; a future RFD 075 revision may define a fuller trust-boundary policy.
- RFD 084: configurable markdown element coloring. D57 extends it: it reuses the
Style/stylesheet design, narrows the SGR-emission boundary to the sink, drops the alias phase, and shares the node-majorstyleshape. TheExtended byback-link on 084 is filled when this draft is promoted. - RFD 096: terminal output sanitization for untrusted content. It neutralizes untrusted content where it enters the render pipeline; this draft's terminal sink adapts already-trusted styling downstream of it. The untrusted-content SGR allowlist is owned by RFD 096, not the sink, so the sink must not grow a second untrusted-content policy.
- color256: terminal 256-color palette generation from base16 (informative).
anstyle,termbg: the crates this RFD reuses or adds.- base16 styling spec: the palette role vocabulary and file format.