RFD D48: Frontend-Neutral Styling and Terminal Color Adaptation
- Status: Draft
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-06-02
- Extends: RFD 048, RFD 004
Summary
Introduce a frontend-neutral style model for JP and a terminal-specific color adapter for the CLI. Renderers resolve semantic styles into their own target's native representation, while jp_printer adapts terminal color bytes to the active terminal profile.
Motivation
JP currently mixes three concerns that need separate homes:
- Semantic style: inline code, tool names, role headers, progress labels, markdown headings, and other UI concepts have meaning before they have a color.
- Frontend rendering: the CLI renders ANSI, a web UI renders CSS, a TUI renders widget styles, and a native app renders platform text attributes.
- Terminal capability: Apple Terminal may expose
TERM=xterm-256colorand reject truecolor even though JP currently emits truecolor unconditionally.
Two user-visible failures fall out of that entanglement:
- Inline code can render as black text on a near-black background on light terminals.
jp_mdsets a background for inline code but leaves the foreground inherited from the terminal. - Syntax-highlighted output can render incorrectly in 256-color terminals. JP emits
38;2;r;g;band48;2;r;g;btruecolor sequences from multiple places without a terminal capability check.
If JP fixes these locally at each call site, every new styled element repeats the same failure mode. If JP puts semantic scope resolution in the terminal sink, the sink receives only bytes and cannot tell whether \x1b[38;2;...] came from inline code, a tool name, a progress line, or relayed tool output.
The design needs one clean rule:
Renderers resolve semantic scopes into their frontend's native style representation. Terminal byte adaptation is a CLI sink concern.
Design
What users see
JP becomes readable on light terminals and correct on 256-color terminals by default. A user in Apple Terminal with TERM=xterm-256color gets 256-color output instead of raw truecolor. A user on a light terminal does not get inline code rendered as dark-background-only text that depends on a dark foreground.
Users can override terminal capability and theme selection:
[style.terminal]
color_depth = "auto" # auto | truecolor | 256 | 16 | none
background = "auto" # auto | light | dark | unknown
[style.theme]
mode = "auto" # auto | dark | light | <theme-name>
dark = "gruvbox-dark"
light = "gruvbox-light"Visual styling lives under element trees. Behavioral settings stay where they already belong.
[style.markdown.elements.inline_code.body]
bg = "base01"
[style.markdown.elements.inline_code.delim]
fg = "base04"
[style.tool_call.elements.function_name]
fg = "base0D"
intensity = "bold"This RFD does not move conversation.tools.*.style.parameters to style.tool_call.parameters. That setting controls how a specific tool's arguments are displayed; it is not a visual style scope.
Architecture
The system has three layers.
1. Shared semantic style
A new pure jp_style crate owns the frontend-neutral vocabulary:
Style: foreground, background, intensity, italic, underline, strikethrough, and related text attributes.Color: palette role, ANSI-256 index, 24-bit RGB, terminal default, or inherit.Palette: base16-style roles such asbase00throughbase0F.Scope: stable semantic paths such asmarkdown.inline_code.bodyandtool_call.function_name.- Stylesheet resolution from scope plus theme to a
Stylevalue.
jp_style does not depend on syntect, does not detect terminals, and does not emit ANSI. It may convert to and from anstyle color values, but it is not a terminal rendering crate.
2. Frontend renderers
Each frontend resolves semantic style while it still has semantic context.
- The CLI markdown renderer in
jp_mdmaps markdown nodes to markdown scopes. It owns or receives thesyntect::Themeused for code highlighting because syntax highlighting belongs to markdown/code rendering, not tojp_style. - CLI chrome renderers in
jp_climap tool call headers, role headers, progress labels, and similar concepts to chrome scopes. - A web frontend maps the same scopes to CSS classes, CSS variables, or inline style objects.
- A TUI frontend maps the same scopes to widget styles.
- A native app maps the same scopes to platform text attributes.
Non-CLI frontends do not use jp_printer and do not receive ANSI strings from jp_cli. If frontend sharing becomes necessary, JP extracts a target-neutral view model or styled-span representation; it does not route other frontends through terminal bytes.
3. Terminal sink
jp_printer remains CLI-specific. It receives bytes and adapts them to the terminal target.
The sink owns:
- per-target color enablement,
- SGR color downgrading,
- stripping color when disabled,
- stripping unsafe control sequences from normal content,
- preserving safe text attributes where allowed,
- correct handling of escape sequences split across writes.
The sink does not own:
- scope resolution,
- markdown semantics,
- tool-call semantics,
- syntect theme selection,
- web/TUI/native rendering decisions.
Terminal profile
Terminal capability detection lives in jp_term.
pub struct ColorProfile {
pub depth: ColorDepth,
pub scheme: Option<ColorScheme>,
}
pub enum ColorDepth {
Truecolor,
Ansi256,
Ansi16,
None,
}
pub enum ColorScheme {
Light,
Dark,
}Color capability is terminal-wide. Color emission is per target. A redirected stdout must not disable styled stderr or /dev/tty prompt output.
pub struct TargetColorProfile {
pub color_enabled: bool,
pub profile: ColorProfile,
}PrintTarget::Out, PrintTarget::Err, and PrintTarget::Tty each receive their own target profile.
Environment and config precedence
Color enablement for each target is resolved in this order:
- Explicit JP config or CLI override.
NO_COLORdisables foreground and background color.CLICOLOR_FORCEenables color even when the target is not a TTY, unlessNO_COLORis set.CLICOLOR=0disables color.- The target's own TTY state enables color.
- Color is disabled.
Color depth is resolved terminal-wide in this order:
- Explicit
style.terminal.color_depth. COLORTERM=truecolororCOLORTERM=24bitselects truecolor.TERMcontaining256colorselects ANSI 256.- Other color-capable
TERMvalues select ANSI 16. - Otherwise, no color.
Background scheme is resolved terminal-wide in this order:
- Explicit
style.terminal.background. COLORFGBG, when present and parseable.- A short-timeout query against the controlling terminal, when available.
None.
None is not dark. If the scheme is unknown, any default style that sets a background must either set a matching foreground or skip the background. This is the readability rule that prevents dark inline-code backgrounds from inheriting black terminal text.
Startup order
The configured printer cannot be fully constructed before config is loaded, but JP still needs sane behavior for bootstrap errors.
Startup proceeds as follows:
- Early bootstrap output uses plain text or minimal unthemed ANSI only.
- Load config.
- Detect per-target TTY state for stdout, stderr, and the controlling terminal.
- Resolve
ColorProfilefrom config, environment, and terminal detection. - Construct or update
Printerwith per-target profiles. - Run command rendering.
Background scheme detection queries the controlling terminal when available. It does not blindly query stdout, because stdout may be redirected while /dev/tty still exists for prompts.
Terminal byte adaptation
jp_printer extends the current AnsiStripper model into an ANSI adapter. The adapter keeps a persistent vte::Parser per sink so split escape sequences are handled correctly.
For normal content writes:
| Sequence category | Styled terminal target | Non-styled target |
|---|---|---|
| SGR foreground/background color | Adapt to target depth or strip | Strip |
| SGR text attributes | Preserve where supported | Strip for structured output |
| OSC 8 hyperlinks | Preserve for terminal text output | Strip |
| Cursor movement, erase, title, clipboard, etc | Strip from normal content | Strip |
JP-owned cursor and erase controls, such as progress-line redraws, use a trusted control path on the printer rather than being written as normal content. The safe failure mode is losing a progress redraw, not leaking relayed control sequences from tool output.
Relationship to RFD 084
RFD 084 defines configurable markdown element coloring inside jp_md. This RFD keeps that direction but changes the boundary:
- Markdown element scopes resolve in
jp_md, where markdown context exists. jp_mdmay emit canonical terminal styles for the CLI, but terminal depth adaptation happens injp_printer.jp_styleowns shared style values and palettes, notsyntect::Theme.- The deprecated
style.inline_code.backgroundkey maps tostyle.markdown.elements.inline_code.body.bgduring config loading.
Drawbacks
- The scope taxonomy becomes user-facing config API. Scope names must be stable once shipped.
- The terminal sink parses output that it writes. This adds runtime overhead, especially for syntax-highlighted code with many color changes.
- The implementation crosses several crates:
jp_style,jp_term,jp_printer,jp_md,jp_cli, andjp_config. - Light and dark default themes require curation. Automatic downgrade alone does not guarantee pleasant colors.
- Configuration migration touches persisted conversation config and deltas, not only user TOML files.
Alternatives
Set an inline-code foreground only
JP could fix the reported inline-code failure by setting a foreground whenever inline code sets a background. This is a useful emergency patch, but it does not fix truecolor output in 256-color terminals and leaves the one-off style plumbing in place.
Emit ANSI 256 colors everywhere
JP could avoid truecolor and use ANSI 256 by default. This fixes Apple Terminal at the cost of worse output on terminals that support truecolor. It also does not solve light/dark readability or frontend-neutral styling.
Put scope resolution in jp_printer
The printer receives bytes, not semantic nodes. By the time output reaches the sink, markdown.inline_code.body and tool_call.function_name have both become SGR sequences plus text. Putting scope resolution there would require callers to annotate every print with provenance, which makes correctness depend on every call site remembering to classify output.
Build a generic frontend abstraction now
A web UI, TUI, and native app can share jp_style, but they should not share a terminal printer. A generic view-model layer can be extracted when a second frontend needs shared rendering. Defining it before that creates abstraction without a second implementation to validate it.
Generate or set the terminal's 256-color palette
The color256 writeup shows how a terminal can generate its 256-color palette from base16 colors. JP adopts the base16 role vocabulary as a style model, but it must not reprogram a user's terminal palette as part of normal output.
Non-Goals
- Build the web, TUI, or native frontend.
- Route non-CLI frontends through
jp_cliorjp_printer. - Define a generic frontend view-model abstraction before a second frontend needs it.
- Move
conversation.tools.*.style.parametersintostyle.tool_call. - Replace
syntector movesyntect::Themeintojp_style. - Reprogram the terminal's 256-color palette.
- Define the full trust policy for tool-emitted terminal controls. This RFD sets the CLI sink default; the broader tool trust model belongs with RFD 075.
Risks and Open Questions
- Performance: parsing and adapting ANSI in the sink costs CPU. The adapter should cache RGB-to-ANSI conversions per
ColorProfile. - Scheme detection reliability: terminal background queries can fail or time out.
Noneremains a first-class result with a readability fallback. - Migration timing:
style.inline_code.backgroundandstyle.markdown.themeare already serialized in configs and conversation deltas. They need explicit aliases before any field is removed from the schema. - Scope naming: markdown scopes can follow RFD 084, but chrome scopes need a smaller first set based on existing rendered elements.
- Bootstrap output: errors before config loading stay unthemed. The user experience must remain readable but does not need the full style system.
Implementation Plan
Phase 1: Terminal profile and sink adapter
- Add
ColorProfile,ColorDepth,ColorScheme, and detection helpers tojp_term. - Add
style.terminal.color_depthandstyle.terminal.backgroundtojp_config. - Extend
jp_printerfrom raw-or-strip sinks to per-target ANSI adapters. - Downgrade SGR colors to truecolor, ANSI 256, ANSI 16, or no color based on the target profile.
- Preserve split-escape correctness with a persistent parser per sink.
- Add tests for split CSI sequences, typewriter output, stdout redirection with styled stderr, and Apple Terminal-style
TERM=xterm-256colordetection.
This phase fixes truecolor output in 256-color terminals and can merge before jp_style exists.
Phase 2: Inline-code readability
- Teach the CLI markdown renderer about the resolved
ColorScheme. - Apply the rule that unknown-scheme background-bearing defaults must set both foreground and background or set neither.
- Add light-scheme and unknown-scheme snapshot tests for inline code.
This phase fixes the black-on-dark inline-code failure.
Phase 3: Shared style model and markdown elements
- Add
jp_stylewithStyle,Color,Palette,Scope, and stylesheet resolution. - Align RFD 084's markdown element tree with
jp_style. - Keep
syntect::Themeselection injp_mdor the CLI formatter construction path, not injp_style. - Replace one-off inline-code background plumbing with
style.markdown.elements.inline_code.body.bg. - Add config aliases for
style.inline_code.backgroundandstyle.markdown.theme.
This phase depends on Phase 2.
Phase 4: CLI chrome scopes
- Add a small first set of chrome scopes where semantic context is still present: role header label, role header suffix, tool call function name, progress label, and progress timer.
- Resolve those scopes in
jp_clirender modules before writing to the printer. - Keep behavioral settings such as
style.tool_call.show,style.tool_call.progress.*, andconversation.tools.*.style.parametersin their current config homes.
This phase depends on Phase 3.
Phase 5: Follow-on frontend sharing
When a second frontend needs shared rendering, extract a target-neutral view model or styled-span layer. This phase is intentionally deferred until the web, TUI, or native frontend exercises the shared boundary.
References
- RFD 004: the
jp_mdstreaming markdown parser and terminal renderer. - RFD 048: the four-channel output model that this RFD extends with per-target color decisions.
- RFD 075: tool sandbox and access policy. This RFD only defines the terminal sink's default escape handling.
- RFD 084: configurable markdown element coloring. This RFD reuses the markdown scope direction and moves shared style values into
jp_style. - color256: an informative writeup on generating 256-color palettes from base16 themes.