Skip to main content

cookie_agent_tui/
theme.rs

1//! Semantic terminal themes and capability-aware color quantization.
2//!
3//! The default theme is a warm, light "cookie" palette: one white surface
4//! (black in the dark table) across the whole frame, espresso-brown text,
5//! and caramel/cinnamon/sage accents. Every TrueColor foreground is chosen for WCAG-AA-or-better
6//! contrast against the white surface; ANSI-256 and ANSI-16 are hue-faithful
7//! degradations. State is never conveyed by color alone (bold/italic/underline
8//! and text markers accompany every semantic color). `Mono` drops all color,
9//! `HighContrast` keeps bright ANSI colors on the terminal's own background.
10//! The dark table re-lights every role; names denote hue, not value.
11
12use ratatui::style::{Color, Modifier, Style};
13
14#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
15pub enum ThemeKind {
16    Default,
17    Dark,
18    Mono,
19    HighContrast,
20}
21
22#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
23pub enum ColorLevel {
24    None,
25    Ansi16,
26    Ansi256,
27    TrueColor,
28}
29
30#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
31pub struct ThemeKey {
32    pub kind: ThemeKind,
33    pub colors: ColorLevel,
34}
35
36/// The semantic color direction of an approval decision button.
37#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
38pub enum DecisionTone {
39    Allow,
40    Deny,
41    Neutral,
42}
43
44#[derive(Clone, Copy, Debug)]
45struct Swatch {
46    rgb: (u8, u8, u8),
47    ansi256: u8,
48    ansi16: Color,
49}
50
51#[derive(Debug)]
52struct Palette {
53    cream: Swatch,
54    terminal: Swatch,
55    parchment: Swatch,
56    glaze: Swatch,
57    toasted: Swatch,
58    crust: Swatch,
59    border: Swatch,
60    tan: Swatch,
61    espresso: Swatch,
62    cocoa: Swatch,
63    latte: Swatch,
64    ash: Swatch,
65    quote: Swatch,
66    caramel: Swatch,
67    sage: Swatch,
68    basil: Swatch,
69    cinnamon: Swatch,
70    cranberry: Swatch,
71    honey: Swatch,
72    maple: Swatch,
73    slate: Swatch,
74    plum: Swatch,
75    terracotta: Swatch,
76    allow_tint: Swatch,
77    deny_tint: Swatch,
78    neutral_tint: Swatch,
79    /// Muted body text: collapsed tool and thinking rows.
80    ink: Swatch,
81    /// Expanded tool title block: one grey step deeper than the `terminal`
82    /// output band, so title and output read as one panel.
83    pan: Swatch,
84}
85
86impl Palette {
87    fn swatches(&self) -> [Swatch; 28] {
88        [
89            self.cream,
90            self.terminal,
91            self.parchment,
92            self.glaze,
93            self.toasted,
94            self.crust,
95            self.border,
96            self.tan,
97            self.espresso,
98            self.cocoa,
99            self.latte,
100            self.ash,
101            self.quote,
102            self.caramel,
103            self.sage,
104            self.basil,
105            self.cinnamon,
106            self.cranberry,
107            self.honey,
108            self.maple,
109            self.slate,
110            self.plum,
111            self.terracotta,
112            self.allow_tint,
113            self.deny_tint,
114            self.neutral_tint,
115            self.ink,
116            self.pan,
117        ]
118    }
119
120    fn swatch_for_rgb(&self, rgb: (u8, u8, u8)) -> Option<Swatch> {
121        self.swatches().into_iter().find(|swatch| swatch.rgb == rgb)
122    }
123}
124
125const fn swatch(rgb: (u8, u8, u8), ansi256: u8, ansi16: Color) -> Swatch {
126    Swatch {
127        rgb,
128        ansi256,
129        ansi16,
130    }
131}
132
133// Role names describe the bakery hue across both tables. The light table is
134// the v2 palette; the dark table re-lights those roles for an espresso surface.
135const LIGHT: Palette = Palette {
136    cream: swatch((0xFF, 0xFF, 0xFF), 231, Color::White),
137    terminal: swatch((0xF0, 0xEE, 0xEA), 255, Color::White),
138    parchment: swatch((0xF2, 0xE5, 0xCC), 230, Color::Gray),
139    glaze: swatch((0xEB, 0xD8, 0xAE), 223, Color::Gray),
140    toasted: swatch((0xE6, 0xCE, 0x9E), 222, Color::Gray),
141    crust: swatch((0xC9, 0xAE, 0x85), 180, Color::LightCyan),
142    border: swatch((0x6B, 0x4F, 0x2C), 240, Color::DarkGray),
143    tan: swatch((0x71, 0x54, 0x30), 240, Color::DarkGray),
144    espresso: swatch((0x46, 0x30, 0x1F), 236, Color::Black),
145    cocoa: swatch((0x6E, 0x4E, 0x38), 95, Color::Cyan),
146    latte: swatch((0x7A, 0x59, 0x41), 95, Color::DarkGray),
147    ash: swatch((0x62, 0x5E, 0x66), 241, Color::DarkGray),
148    quote: swatch((0x6F, 0x62, 0x50), 243, Color::DarkGray),
149    caramel: swatch((0x9C, 0x5A, 0x10), 130, Color::Yellow),
150    sage: swatch((0x4E, 0x7A, 0x34), 65, Color::Green),
151    basil: swatch((0x2F, 0x6B, 0x38), 29, Color::Green),
152    cinnamon: swatch((0x9C, 0x4A, 0x12), 131, Color::Yellow),
153    cranberry: swatch((0xAE, 0x33, 0x27), 88, Color::Red),
154    honey: swatch((0x7A, 0x52, 0x06), 94, Color::Yellow),
155    maple: swatch((0x8A, 0x4A, 0x0B), 58, Color::Black),
156    slate: swatch((0x3D, 0x6A, 0x8C), 24, Color::Blue),
157    plum: swatch((0x8A, 0x55, 0x70), 96, Color::Magenta),
158    terracotta: swatch((0xA8, 0x47, 0x1C), 124, Color::Red),
159    allow_tint: swatch((0xDE, 0xE7, 0xC6), 194, Color::LightGreen),
160    deny_tint: swatch((0xF3, 0xD5, 0xC9), 224, Color::LightRed),
161    neutral_tint: swatch((0xE6, 0xDC, 0xC6), 187, Color::Gray),
162    ink: swatch((0x62, 0x52, 0x40), 239, Color::DarkGray),
163    pan: swatch((0xE7, 0xE4, 0xDF), 254, Color::Gray),
164};
165
166const DARK: Palette = Palette {
167    cream: swatch((0x00, 0x00, 0x00), 16, Color::Black),
168    terminal: swatch((0x2A, 0x29, 0x27), 235, Color::Black),
169    parchment: swatch((0x39, 0x33, 0x26), 236, Color::DarkGray),
170    glaze: swatch((0x4C, 0x43, 0x30), 238, Color::DarkGray),
171    toasted: swatch((0x63, 0x56, 0x3B), 240, Color::Gray),
172    crust: swatch((0x7E, 0x68, 0x47), 242, Color::LightCyan),
173    border: swatch((0xC0, 0x99, 0x6A), 180, Color::DarkGray),
174    tan: swatch((0xBB, 0x93, 0x61), 180, Color::DarkGray),
175    espresso: swatch((0xED, 0xC7, 0xAB), 223, Color::White),
176    cocoa: swatch((0xD4, 0x96, 0x6C), 173, Color::LightCyan),
177    latte: swatch((0xBE, 0x8A, 0x65), 137, Color::Gray),
178    ash: swatch((0x9B, 0x95, 0xA1), 246, Color::DarkGray),
179    quote: swatch((0xA1, 0x8F, 0x74), 102, Color::DarkGray),
180    caramel: swatch((0xC7, 0x77, 0x1E), 172, Color::Yellow),
181    sage: swatch((0x60, 0x97, 0x40), 107, Color::LightGreen),
182    basil: swatch((0x4A, 0xAA, 0x59), 71, Color::LightGreen),
183    cinnamon: swatch((0xD9, 0x82, 0x46), 173, Color::Yellow),
184    cranberry: swatch((0xF2, 0x6E, 0x61), 203, Color::LightRed),
185    honey: swatch((0xCC, 0x95, 0x2D), 178, Color::Yellow),
186    maple: swatch((0xE7, 0x8C, 0x33), 215, Color::White),
187    slate: swatch((0x57, 0x97, 0xC8), 67, Color::LightBlue),
188    plum: swatch((0xC5, 0x7A, 0xA0), 175, Color::LightMagenta),
189    terracotta: swatch((0xE6, 0x6E, 0x3A), 209, Color::LightRed),
190    allow_tint: swatch((0x82, 0x92, 0x57), 71, Color::LightGreen),
191    deny_tint: swatch((0xBF, 0x7B, 0x5F), 203, Color::LightRed),
192    neutral_tint: swatch((0x96, 0x8A, 0x71), 173, Color::Gray),
193    ink: swatch((0xC9, 0xB5, 0x9C), 180, Color::Gray),
194    pan: swatch((0x36, 0x34, 0x31), 237, Color::DarkGray),
195};
196
197#[derive(Clone, Debug)]
198pub struct Theme {
199    key: ThemeKey,
200}
201
202impl Default for Theme {
203    fn default() -> Self {
204        Self::new(ThemeKind::Default, ColorLevel::TrueColor)
205    }
206}
207
208impl Theme {
209    pub fn from_env() -> Self {
210        let requested = std::env::var("COOKIE_THEME").unwrap_or_default();
211        let no_color = std::env::var_os("NO_COLOR").is_some();
212        let term = std::env::var("TERM").unwrap_or_default();
213        Self::from_environment(&requested, no_color, &term, &env_colorterm())
214    }
215
216    /// Apply a theme kind chosen by the TUI config file while still honoring
217    /// `NO_COLOR`/`TERM=dumb` and detected terminal color capability.
218    pub fn with_kind_from_env(kind: ThemeKind) -> Self {
219        let no_color = std::env::var_os("NO_COLOR").is_some();
220        let term = std::env::var("TERM").unwrap_or_default();
221        Self::from_kind_environment(kind, no_color, &term, &env_colorterm())
222    }
223
224    pub fn from_environment(requested: &str, no_color: bool, term: &str, colorterm: &str) -> Self {
225        let requested_kind = match requested.to_ascii_lowercase().as_str() {
226            "auto" => ThemeKind::Default,
227            "dark" | "dark-roast" | "darkroast" => ThemeKind::Dark,
228            "mono" | "monochrome" => ThemeKind::Mono,
229            "high-contrast" | "high_contrast" | "contrast" => ThemeKind::HighContrast,
230            _ => ThemeKind::Default,
231        };
232        Self::from_kind_environment(requested_kind, no_color, term, colorterm)
233    }
234
235    pub fn from_kind_environment(
236        requested_kind: ThemeKind,
237        no_color: bool,
238        term: &str,
239        colorterm: &str,
240    ) -> Self {
241        let colors =
242            if requested_kind == ThemeKind::Mono || no_color || term.eq_ignore_ascii_case("dumb") {
243                ColorLevel::None
244            } else if requested_kind == ThemeKind::HighContrast {
245                ColorLevel::Ansi16
246            } else if colorterm.eq_ignore_ascii_case("truecolor")
247                || colorterm.eq_ignore_ascii_case("24bit")
248            {
249                ColorLevel::TrueColor
250            } else if term.contains("256color") {
251                ColorLevel::Ansi256
252            } else {
253                ColorLevel::Ansi16
254            };
255        let kind = if colors == ColorLevel::None {
256            ThemeKind::Mono
257        } else {
258            requested_kind
259        };
260        Self::new(kind, colors)
261    }
262
263    pub const fn new(kind: ThemeKind, colors: ColorLevel) -> Self {
264        let colors = match (kind, colors) {
265            (ThemeKind::Mono, _) | (ThemeKind::HighContrast, ColorLevel::None) => ColorLevel::None,
266            (ThemeKind::HighContrast, _) => ColorLevel::Ansi16,
267            (ThemeKind::Default | ThemeKind::Dark, colors) => colors,
268        };
269        Self {
270            key: ThemeKey { kind, colors },
271        }
272    }
273
274    pub const fn key(&self) -> ThemeKey {
275        self.key
276    }
277
278    const fn palette(&self) -> &'static Palette {
279        match self.key.kind {
280            ThemeKind::Dark => &DARK,
281            ThemeKind::Default | ThemeKind::Mono | ThemeKind::HighContrast => &LIGHT,
282        }
283    }
284
285    const fn is_bakery_palette(&self) -> bool {
286        matches!(self.key.kind, ThemeKind::Default | ThemeKind::Dark)
287    }
288
289    /// Base surface painted beneath the bakery-palette UI. Mono and
290    /// high-contrast themes keep the terminal background.
291    pub fn surface(&self) -> Style {
292        if !self.is_bakery_palette() {
293            return Style::default();
294        }
295        let palette = self.palette();
296        match self.key.colors {
297            ColorLevel::None => Style::default(),
298            ColorLevel::Ansi16 => Style::default()
299                .fg(palette.espresso.ansi16)
300                .bg(palette.cream.ansi16),
301            ColorLevel::Ansi256 => Style::default()
302                .fg(Color::Indexed(palette.espresso.ansi256))
303                .bg(Color::Indexed(palette.cream.ansi256)),
304            ColorLevel::TrueColor => Style::default()
305                .fg(Color::Rgb(
306                    palette.espresso.rgb.0,
307                    palette.espresso.rgb.1,
308                    palette.espresso.rgb.2,
309                ))
310                .bg(Color::Rgb(
311                    palette.cream.rgb.0,
312                    palette.cream.rgb.1,
313                    palette.cream.rgb.2,
314                )),
315        }
316    }
317
318    /// Panels (overlays, pickers, the focused input box, the bottom bar)
319    /// share the one cream surface — borders, not a second fill, delineate
320    /// them. Foreground is left alone so content keeps whatever the surface
321    /// painted.
322    pub fn panel(&self) -> Style {
323        self.background_color(self.palette().cream, None)
324            .map_or_else(Style::default, |background| Style::default().bg(background))
325    }
326
327    /// Walnut border for pane chrome (conversation, tree, pickers), dark
328    /// enough to keep the frame visible on the cream surface.
329    pub fn panel_border(&self) -> Style {
330        self.semantic(self.palette().border, Color::White, Modifier::DIM)
331    }
332
333    /// Keyboard-selected row in pickers, palettes, and the session tree: a
334    /// toasted background with bold espresso text. Reverse video carries the
335    /// selection where no color is available.
336    pub fn selected(&self) -> Style {
337        let style = Style::default().add_modifier(Modifier::BOLD);
338        match self.key.colors {
339            ColorLevel::None => Style::default().add_modifier(Modifier::REVERSED),
340            _ if self.key.kind == ThemeKind::HighContrast => {
341                style.fg(Color::Black).bg(Color::LightYellow)
342            }
343            ColorLevel::Ansi16 => style.fg(Color::Black).bg(self.palette().toasted.ansi16),
344            ColorLevel::Ansi256 | ColorLevel::TrueColor => {
345                let palette = self.palette();
346                let mut style = style;
347                if let Some(foreground) = self.swatch_color(palette.espresso) {
348                    style = style.fg(foreground);
349                }
350                if let Some(background) = self.swatch_color(palette.toasted) {
351                    style = style.bg(background);
352                }
353                style
354            }
355        }
356    }
357
358    /// Selection surface without foreground or emphasis, for rows whose spans
359    /// retain their own visual hierarchy.
360    pub fn selected_overlay(&self) -> Style {
361        let selected = self.selected();
362        let mut overlay = selected
363            .bg
364            .map_or_else(Style::default, |background| Style::default().bg(background));
365        if selected.add_modifier.contains(Modifier::REVERSED) {
366            overlay = overlay.add_modifier(Modifier::REVERSED);
367        }
368        overlay
369    }
370
371    /// Mouse-drag text selection: a deeper crust wash than the keyboard
372    /// `selected` row and never bold, so the two selection kinds never read
373    /// alike. Where subtle color exists (ANSI-256, true color) only the
374    /// background is set — patched cells keep their foregrounds, so
375    /// selected code keeps its syntax colors. ANSI-16 and high-contrast
376    /// targets are the one documented exception: their text is always a
377    /// bright color, which a light-cyan wash would swallow, so the pair is
378    /// pinned to black-on-light-cyan. Cruder targets add bold on top of
379    /// reverse video to stay distinct.
380    pub fn text_selection(&self) -> Style {
381        match self.key.colors {
382            ColorLevel::None => Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
383            _ if self.key.kind == ThemeKind::HighContrast => {
384                Style::default().fg(Color::Black).bg(Color::LightCyan)
385            }
386            ColorLevel::Ansi16 => Style::default()
387                .fg(Color::Black)
388                .bg(self.palette().crust.ansi16),
389            ColorLevel::Ansi256 | ColorLevel::TrueColor => self
390                .swatch_color(self.palette().crust)
391                .map_or_else(Style::default, |background| Style::default().bg(background)),
392        }
393    }
394
395    /// Hover affordance patched over interactive text cells: a quiet glaze
396    /// background — one calm step deeper than the cream surface, never a
397    /// reddish tint. Cruder targets (mono, ANSI-16, high contrast) underline
398    /// as well, so the affordance never relies on a subtle background shift
399    /// alone. Existing foreground colors are preserved by the patch.
400    pub fn hover(&self) -> Style {
401        let quiet_background_only = matches!(
402            (self.key.kind, self.key.colors),
403            (
404                ThemeKind::Default | ThemeKind::Dark,
405                ColorLevel::Ansi256 | ColorLevel::TrueColor
406            )
407        );
408        let style = if quiet_background_only {
409            Style::default()
410        } else {
411            Style::default().add_modifier(Modifier::UNDERLINED)
412        };
413        self.background_color(self.palette().glaze, Some(Color::DarkGray))
414            .map_or(style, |background| style.bg(background))
415    }
416
417    /// Background-only hover fill for approval buttons and other glyph
418    /// cells where an underline would not read.
419    pub fn hover_fill(&self) -> Style {
420        self.background_color(self.palette().glaze, Some(Color::DarkGray))
421            .map_or_else(Style::default, |background| Style::default().bg(background))
422    }
423
424    /// Subtle parchment band behind fenced code blocks. The bakery themes
425    /// paint one; mono and high-contrast keep blocks flat.
426    pub fn code_background(&self) -> Option<Color> {
427        if !self.is_bakery_palette() {
428            return None;
429        }
430        self.background_color(self.palette().parchment, None)
431    }
432
433    /// Muted grey band for shell output: neutral beside the warm code
434    /// tint, so command output never reads as highlighted source.
435    pub fn terminal_background(&self) -> Option<Color> {
436        if !self.is_bakery_palette() {
437            return None;
438        }
439        self.background_color(self.palette().terminal, None)
440    }
441
442    /// Background of an expanded tool's title block (header plus the rest of
443    /// its primary argument): a grey one step deeper than the output band.
444    /// Sixteen-colour terminals have no grey ladder, so the block stays
445    /// untinted there rather than collide with the hover fill.
446    pub fn tool_title_background(&self) -> Option<Color> {
447        if !self.is_bakery_palette() || self.key.colors == ColorLevel::Ansi16 {
448            return None;
449        }
450        self.background_color(self.palette().pan, None)
451    }
452
453    /// Interactive block hover preserves foregrounds and never underlines.
454    pub fn block_hover(&self) -> Style {
455        let style = self.hover_fill().remove_modifier(Modifier::UNDERLINED);
456        if self.key.colors == ColorLevel::None {
457            style.add_modifier(Modifier::BOLD)
458        } else {
459            style
460        }
461    }
462
463    /// One approval decision button. `active` (hover) fills the button with
464    /// a decision-tinted background; the border/label color and the button
465    /// glyphs carry the meaning, never color alone.
466    pub fn decision(&self, tone: DecisionTone, active: bool) -> Style {
467        if self.key.colors == ColorLevel::None {
468            let mut style = Style::default().add_modifier(Modifier::BOLD);
469            if active {
470                style = style.add_modifier(Modifier::REVERSED);
471            }
472            return style;
473        }
474        if self.key.kind == ThemeKind::HighContrast {
475            let foreground = match tone {
476                DecisionTone::Allow => Color::LightGreen,
477                DecisionTone::Deny => Color::LightRed,
478                DecisionTone::Neutral => Color::White,
479            };
480            let mut style = Style::default().fg(foreground).add_modifier(Modifier::BOLD);
481            if active {
482                style = style.add_modifier(Modifier::REVERSED);
483            }
484            return style;
485        }
486        if self.key.colors == ColorLevel::Ansi16 {
487            let palette = self.palette();
488            if active {
489                let background = match tone {
490                    DecisionTone::Allow => palette.allow_tint.ansi16,
491                    DecisionTone::Deny => palette.deny_tint.ansi16,
492                    DecisionTone::Neutral => palette.neutral_tint.ansi16,
493                };
494                return Style::default()
495                    .fg(Color::Black)
496                    .bg(background)
497                    .add_modifier(Modifier::BOLD);
498            }
499            let foreground = match tone {
500                DecisionTone::Allow => palette.basil.ansi16,
501                DecisionTone::Deny => palette.cranberry.ansi16,
502                DecisionTone::Neutral => palette.cocoa.ansi16,
503            };
504            return Style::default().fg(foreground).add_modifier(Modifier::BOLD);
505        }
506        let palette = self.palette();
507        let (foreground, tint) = match tone {
508            DecisionTone::Allow => (palette.basil, palette.allow_tint),
509            DecisionTone::Deny => (palette.cranberry, palette.deny_tint),
510            DecisionTone::Neutral => (palette.cocoa, palette.neutral_tint),
511        };
512        let mut style = Style::default().add_modifier(Modifier::BOLD);
513        let foreground = if active && self.key.kind == ThemeKind::Dark {
514            palette.cream
515        } else {
516            foreground
517        };
518        if let Some(foreground) = self.swatch_color(foreground) {
519            style = style.fg(foreground);
520        }
521        if active && let Some(background) = self.swatch_color(tint) {
522            style = style.bg(background);
523        }
524        style
525    }
526
527    pub fn body(&self) -> Style {
528        Style::default()
529    }
530
531    pub fn muted(&self) -> Style {
532        self.semantic(self.palette().latte, Color::White, Modifier::DIM)
533    }
534
535    /// The body text colour one step quieter, for rows that summarise hidden
536    /// content: collapsed tool calls and thinking.
537    pub fn muted_text(&self) -> Style {
538        self.semantic(self.palette().ink, Color::White, Modifier::DIM)
539    }
540
541    pub fn user(&self) -> Style {
542        self.semantic(self.palette().caramel, Color::LightCyan, Modifier::BOLD)
543    }
544
545    pub fn assistant(&self) -> Style {
546        self.semantic(self.palette().sage, Color::White, Modifier::BOLD)
547    }
548
549    pub fn thinking(&self) -> Style {
550        self.semantic(self.palette().plum, Color::LightMagenta, Modifier::ITALIC)
551    }
552
553    /// Tool rows sit under the bold agent header, so colour alone carries
554    /// them; colourless themes keep bold as the only cue left.
555    pub fn tool(&self) -> Style {
556        self.semantic(
557            self.palette().cinnamon,
558            Color::LightYellow,
559            self.weight_without_color(),
560        )
561    }
562
563    pub fn tool_running(&self) -> Style {
564        self.tool()
565    }
566
567    pub fn tool_success(&self) -> Style {
568        self.semantic(
569            self.palette().basil,
570            Color::LightGreen,
571            self.weight_without_color(),
572        )
573    }
574
575    /// Bold only where there is no colour to distinguish a row.
576    fn weight_without_color(&self) -> Modifier {
577        if self.key.colors == ColorLevel::None {
578            Modifier::BOLD
579        } else {
580            Modifier::empty()
581        }
582    }
583
584    pub fn tool_failure(&self) -> Style {
585        self.error()
586    }
587
588    /// Warning style — visually distinct from error styling (never red).
589    pub fn warning(&self) -> Style {
590        self.semantic(self.palette().honey, Color::LightYellow, Modifier::BOLD)
591    }
592
593    pub fn error(&self) -> Style {
594        self.semantic(self.palette().cranberry, Color::LightRed, Modifier::BOLD)
595    }
596
597    pub fn internal(&self) -> Style {
598        self.semantic(self.palette().ash, Color::Gray, Modifier::DIM)
599    }
600
601    pub fn heading(&self) -> Style {
602        let style = self.semantic(self.palette().maple, Color::White, Modifier::BOLD);
603        if self.key.kind == ThemeKind::HighContrast && self.key.colors != ColorLevel::None {
604            style.add_modifier(Modifier::UNDERLINED)
605        } else {
606            style
607        }
608    }
609
610    pub fn link(&self) -> Style {
611        self.semantic(
612            self.palette().slate,
613            Color::LightCyan,
614            Modifier::UNDERLINED | Modifier::BOLD,
615        )
616    }
617
618    /// Inline code in assistant Markdown: a distinct warm terracotta
619    /// foreground, never a background in the default theme — the source
620    /// backticks stay visible, and the bold modifier carries the distinction
621    /// in mono terminals. High contrast keeps its inverse-video chip.
622    /// Inline code. Bakery palettes set it in a muted body-text colour on the
623    /// code-block parchment, in regular weight, which is marker enough that
624    /// the renderer drops the backticks; other themes keep bold text and
625    /// the backticks.
626    /// Inline code in the bakery palettes is text, not a chip: a close
627    /// cousin of the body colour (cocoa beside espresso) with no tint, so it
628    /// never breaks a line into padded blocks. Sixteen colours have no such
629    /// near shade and keep the body colour on the code tint; colourless and
630    /// high-contrast themes keep a weight or chip cue.
631    pub fn inline_code(&self) -> Style {
632        if self.inline_code_is_coloured() {
633            return self.semantic(self.palette().cocoa, Color::Black, Modifier::empty());
634        }
635        if let Some(background) = self.code_background() {
636            return self
637                .semantic(self.palette().espresso, Color::Black, Modifier::empty())
638                .bg(background);
639        }
640        let foreground = self.semantic(self.palette().terracotta, Color::Black, Modifier::BOLD);
641        let background = match self.key.colors {
642            ColorLevel::None => None,
643            _ if self.key.kind == ThemeKind::HighContrast => Some(Color::LightYellow),
644            ColorLevel::Ansi16 | ColorLevel::Ansi256 | ColorLevel::TrueColor => None,
645        };
646        background.map_or(foreground, |background| foreground.bg(background))
647    }
648
649    /// Whether inline code's own text colour marks it, so it needs neither a
650    /// chip nor backticks.
651    pub fn inline_code_is_coloured(&self) -> bool {
652        self.is_bakery_palette()
653            && matches!(self.key.colors, ColorLevel::Ansi256 | ColorLevel::TrueColor)
654    }
655
656    /// The half-block caps (`▐` before, `▌` after) that widen a tinted
657    /// inline-code chip by half a cell on each side: foreground in the chip's
658    /// background colour over the surrounding surface. `None` where inline
659    /// code has no tint.
660    pub fn inline_code_cap(&self) -> Option<Style> {
661        self.inline_code()
662            .bg
663            .map(|background| Style::default().fg(background))
664    }
665
666    pub fn code_border(&self) -> Style {
667        self.semantic(self.palette().tan, Color::White, Modifier::DIM)
668    }
669
670    pub fn code_gutter(&self) -> Style {
671        self.muted()
672    }
673
674    pub fn diff_added(&self) -> Style {
675        self.semantic(self.palette().basil, Color::LightGreen, Modifier::BOLD)
676    }
677
678    pub fn diff_removed(&self) -> Style {
679        self.semantic(self.palette().cranberry, Color::LightRed, Modifier::BOLD)
680    }
681
682    pub fn diff_hunk(&self) -> Style {
683        self.semantic(self.palette().slate, Color::LightCyan, Modifier::BOLD)
684    }
685
686    pub fn quote(&self) -> Style {
687        self.semantic(self.palette().quote, Color::LightMagenta, Modifier::ITALIC)
688    }
689
690    pub fn scrollbar_thumb(&self) -> Style {
691        self.semantic(self.palette().espresso, Color::White, Modifier::DIM)
692    }
693
694    pub fn input_border(&self, focused: bool) -> Style {
695        if focused {
696            // The theme's highlight is honey yellow, not the reddish caramel
697            // of user identity — focus reads as warmth, not as an error-adjacent red.
698            if self.key.kind == ThemeKind::Dark && self.key.colors == ColorLevel::Ansi16 {
699                Style::default()
700                    .fg(Color::LightYellow)
701                    .add_modifier(Modifier::BOLD)
702            } else {
703                self.semantic(self.palette().honey, Color::LightYellow, Modifier::BOLD)
704            }
705        } else {
706            self.panel_border()
707        }
708    }
709
710    pub fn quantize_rgb(&self, red: u8, green: u8, blue: u8) -> Option<Color> {
711        match self.key.colors {
712            ColorLevel::None => None,
713            _ if self.key.kind == ThemeKind::HighContrast => {
714                Some(nearest_high_contrast(red, green, blue))
715            }
716            ColorLevel::TrueColor => Some(Color::Rgb(red, green, blue)),
717            ColorLevel::Ansi256 => Some(Color::Indexed(
718                self.palette()
719                    .swatch_for_rgb((red, green, blue))
720                    .map_or_else(|| rgb_to_ansi256(red, green, blue), |swatch| swatch.ansi256),
721            )),
722            ColorLevel::Ansi16 => Some(
723                self.palette()
724                    .swatch_for_rgb((red, green, blue))
725                    .map_or_else(|| nearest_ansi16(red, green, blue), |swatch| swatch.ansi16),
726            ),
727        }
728    }
729
730    fn swatch_color(&self, swatch: Swatch) -> Option<Color> {
731        match self.key.colors {
732            ColorLevel::None => None,
733            _ if self.key.kind == ThemeKind::HighContrast => Some(nearest_high_contrast(
734                swatch.rgb.0,
735                swatch.rgb.1,
736                swatch.rgb.2,
737            )),
738            ColorLevel::Ansi16 => Some(swatch.ansi16),
739            ColorLevel::Ansi256 => Some(Color::Indexed(swatch.ansi256)),
740            ColorLevel::TrueColor => Some(Color::Rgb(swatch.rgb.0, swatch.rgb.1, swatch.rgb.2)),
741        }
742    }
743
744    /// A background color honoring kind/level fallbacks from its palette
745    /// swatch. High contrast paints only explicitly requested backgrounds.
746    fn background_color(&self, swatch: Swatch, high_contrast: Option<Color>) -> Option<Color> {
747        match self.key.colors {
748            ColorLevel::None => None,
749            _ if self.key.kind == ThemeKind::HighContrast => high_contrast,
750            ColorLevel::Ansi16 => Some(swatch.ansi16),
751            ColorLevel::Ansi256 => Some(Color::Indexed(swatch.ansi256)),
752            ColorLevel::TrueColor => Some(Color::Rgb(swatch.rgb.0, swatch.rgb.1, swatch.rgb.2)),
753        }
754    }
755
756    fn semantic(&self, swatch: Swatch, high_contrast: Color, modifier: Modifier) -> Style {
757        let mut modifier = match self.key.kind {
758            ThemeKind::HighContrast => modifier | Modifier::BOLD,
759            ThemeKind::Default | ThemeKind::Dark | ThemeKind::Mono => modifier,
760        };
761        if self.is_bakery_palette() {
762            modifier.remove(Modifier::DIM);
763        }
764        let color = match self.key.colors {
765            ColorLevel::None => None,
766            _ if self.key.kind == ThemeKind::HighContrast => Some(high_contrast),
767            ColorLevel::Ansi16 => Some(swatch.ansi16),
768            ColorLevel::Ansi256 => Some(Color::Indexed(swatch.ansi256)),
769            ColorLevel::TrueColor => Some(Color::Rgb(swatch.rgb.0, swatch.rgb.1, swatch.rgb.2)),
770        };
771        color.map_or_else(
772            || Style::default().add_modifier(modifier),
773            |color| Style::default().fg(color).add_modifier(modifier),
774        )
775    }
776}
777
778/// `COLORTERM`, or `truecolor` for a terminal known to render 24-bit colour
779/// that does not advertise it there: Windows Terminal (`WT_SESSION`, which it
780/// also shares into WSL) and a few `TERM_PROGRAM` identities.
781fn env_colorterm() -> String {
782    let colorterm = std::env::var("COLORTERM").unwrap_or_default();
783    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
784    colorterm_with_hints(
785        colorterm,
786        std::env::var_os("WT_SESSION").is_some(),
787        &term_program,
788    )
789}
790
791fn colorterm_with_hints(colorterm: String, windows_terminal: bool, term_program: &str) -> String {
792    let known_truecolor =
793        windows_terminal || matches!(term_program, "vscode" | "iTerm.app" | "WezTerm" | "ghostty");
794    if colorterm.is_empty() && known_truecolor {
795        "truecolor".to_owned()
796    } else {
797        colorterm
798    }
799}
800
801fn nearest_high_contrast(red: u8, green: u8, blue: u8) -> Color {
802    const COLORS: [(Color, (u8, u8, u8)); 7] = [
803        (Color::LightRed, (255, 0, 0)),
804        (Color::LightGreen, (0, 255, 0)),
805        (Color::LightYellow, (255, 255, 0)),
806        (Color::LightBlue, (0, 0, 255)),
807        (Color::LightMagenta, (255, 0, 255)),
808        (Color::LightCyan, (0, 255, 255)),
809        (Color::White, (255, 255, 255)),
810    ];
811    nearest_color(red, green, blue, &COLORS)
812}
813
814fn rgb_to_ansi256(red: u8, green: u8, blue: u8) -> u8 {
815    let component = |value: u8| ((u16::from(value) * 5 + 127) / 255) as u8;
816    16 + 36 * component(red) + 6 * component(green) + component(blue)
817}
818
819fn nearest_ansi16(red: u8, green: u8, blue: u8) -> Color {
820    const COLORS: [(Color, (u8, u8, u8)); 16] = [
821        (Color::Black, (0, 0, 0)),
822        (Color::Red, (128, 0, 0)),
823        (Color::Green, (0, 128, 0)),
824        (Color::Yellow, (128, 128, 0)),
825        (Color::Blue, (0, 0, 128)),
826        (Color::Magenta, (128, 0, 128)),
827        (Color::Cyan, (0, 128, 128)),
828        (Color::Gray, (192, 192, 192)),
829        (Color::DarkGray, (128, 128, 128)),
830        (Color::LightRed, (255, 0, 0)),
831        (Color::LightGreen, (0, 255, 0)),
832        (Color::LightYellow, (255, 255, 0)),
833        (Color::LightBlue, (0, 0, 255)),
834        (Color::LightMagenta, (255, 0, 255)),
835        (Color::LightCyan, (0, 255, 255)),
836        (Color::White, (255, 255, 255)),
837    ];
838    nearest_color(red, green, blue, &COLORS)
839}
840
841fn nearest_color(red: u8, green: u8, blue: u8, colors: &[(Color, (u8, u8, u8))]) -> Color {
842    colors
843        .iter()
844        .min_by_key(|(_, (candidate_red, candidate_green, candidate_blue))| {
845            let red = i32::from(red) - i32::from(*candidate_red);
846            let green = i32::from(green) - i32::from(*candidate_green);
847            let blue = i32::from(blue) - i32::from(*candidate_blue);
848            red * red + green * green + blue * blue
849        })
850        .map_or(Color::White, |(color, _)| *color)
851}
852
853#[cfg(test)]
854mod tests;