Skip to main content

pi/modes/interactive/
theme.rs

1//! Product theme: resolved colors, built-in defaults, JSON loading, and
2//! conversion to pi-tui component themes.
3//!
4//! Ports `.references/pi/packages/coding-agent/src/modes/interactive/theme/`
5//! (`theme.ts`, `dark.json`, `light.json`). The reference uses a global
6//! singleton `theme`; pi-tui component themes take `fn(&str) -> String` hooks
7//! that cannot capture state, so this module mirrors the singleton with a
8//! thread-local *current* [`Arc<ResolvedTheme>`] that the `fn`-pointer color
9//! helpers read. Built-in themes are [`LazyLock`] interns; loaded themes are
10//! returned as owned [`Arc`] handles (no leaks).
11//!
12//! # Fallibility
13//!
14//! [`ThemeJson`] validates structure. [`ThemeJson::resolve`] fails on missing
15//! colors, bad hex, unknown variable references, or circular variable chains.
16//! The view-model falls back to the built-in dark theme on any error so a bad
17//! `theme.json` never breaks the terminal — see [`dark`] and the
18//! `theme_invalid_falls_back_to_dark` test.
19
20use std::borrow::Cow;
21use std::cell::RefCell;
22use std::sync::{Arc, LazyLock};
23
24pub use pi_tui::components::{DefaultTextStyle, MarkdownOptions, MarkdownTheme};
25use pi_tui::components::{SelectListTheme, SettingsListTheme};
26use pi_tui::text::{truncate_to_width, visible_width};
27
28use crate::core::config;
29
30/// Terminal color depth selected at startup.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum ColorMode {
33    /// 24-bit `CSI 38;2;r;g;b` sequences.
34    Truecolor,
35    /// Downsampled `CSI 38;5;n` 256-color palette.
36    Palette256,
37}
38
39/// Product foreground/text color slots (see reference `ThemeColor`).
40///
41/// Order matches the JSON schema and the reference type alias.
42#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
43pub enum ThemeColor {
44    /// Core UI.
45    Accent,
46    /// Border default.
47    Border,
48    /// Accent border.
49    BorderAccent,
50    /// Muted border.
51    BorderMuted,
52    /// Success foreground.
53    Success,
54    /// Error foreground.
55    Error,
56    /// Warning foreground.
57    Warning,
58    /// Muted foreground.
59    Muted,
60    /// Dim foreground.
61    Dim,
62    /// Default text foreground.
63    Text,
64    /// Reasoning text foreground.
65    ThinkingText,
66    /// User message foreground.
67    UserMessageText,
68    /// Custom message foreground.
69    CustomMessageText,
70    /// Custom message label.
71    CustomMessageLabel,
72    /// Tool title.
73    ToolTitle,
74    /// Tool output.
75    ToolOutput,
76    /// Markdown heading.
77    MdHeading,
78    /// Markdown link.
79    MdLink,
80    /// Markdown link URL suffix.
81    MdLinkUrl,
82    /// Markdown inline code.
83    MdCode,
84    /// Markdown code block body.
85    MdCodeBlock,
86    /// Markdown code block border.
87    MdCodeBlockBorder,
88    /// Markdown quote body.
89    MdQuote,
90    /// Markdown quote border.
91    MdQuoteBorder,
92    /// Markdown horizontal rule.
93    MdHr,
94    /// Markdown list bullet.
95    MdListBullet,
96    /// Diff added line.
97    ToolDiffAdded,
98    /// Diff removed line.
99    ToolDiffRemoved,
100    /// Diff context line.
101    ToolDiffContext,
102    /// Syntax comment.
103    SyntaxComment,
104    /// Syntax keyword.
105    SyntaxKeyword,
106    /// Syntax function.
107    SyntaxFunction,
108    /// Syntax variable.
109    SyntaxVariable,
110    /// Syntax string.
111    SyntaxString,
112    /// Syntax number.
113    SyntaxNumber,
114    /// Syntax type.
115    SyntaxType,
116    /// Syntax operator.
117    SyntaxOperator,
118    /// Syntax punctuation.
119    SyntaxPunctuation,
120    /// Thinking-off border.
121    ThinkingOff,
122    /// Thinking-minimal border.
123    ThinkingMinimal,
124    /// Thinking-low border.
125    ThinkingLow,
126    /// Thinking-medium border.
127    ThinkingMedium,
128    /// Thinking-high border.
129    ThinkingHigh,
130    /// Thinking-xhigh border.
131    ThinkingXhigh,
132    /// Thinking-max border (falls back to xhigh).
133    ThinkingMax,
134    /// Bash-mode border.
135    BashMode,
136}
137
138/// Product background color slots (see reference `ThemeBg`).
139#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
140pub enum ThemeBg {
141    /// Selected row background.
142    SelectedBg,
143    /// User message background.
144    UserMessageBg,
145    /// Custom message background.
146    CustomMessageBg,
147    /// Pending tool background.
148    ToolPendingBg,
149    /// Successful tool background.
150    ToolSuccessBg,
151    /// Errored tool background.
152    ToolErrorBg,
153}
154
155/// All foreground slots in schema order.
156pub const ALL_FG: [ThemeColor; 46] = [
157    ThemeColor::Accent,
158    ThemeColor::Border,
159    ThemeColor::BorderAccent,
160    ThemeColor::BorderMuted,
161    ThemeColor::Success,
162    ThemeColor::Error,
163    ThemeColor::Warning,
164    ThemeColor::Muted,
165    ThemeColor::Dim,
166    ThemeColor::Text,
167    ThemeColor::ThinkingText,
168    ThemeColor::UserMessageText,
169    ThemeColor::CustomMessageText,
170    ThemeColor::CustomMessageLabel,
171    ThemeColor::ToolTitle,
172    ThemeColor::ToolOutput,
173    ThemeColor::MdHeading,
174    ThemeColor::MdLink,
175    ThemeColor::MdLinkUrl,
176    ThemeColor::MdCode,
177    ThemeColor::MdCodeBlock,
178    ThemeColor::MdCodeBlockBorder,
179    ThemeColor::MdQuote,
180    ThemeColor::MdQuoteBorder,
181    ThemeColor::MdHr,
182    ThemeColor::MdListBullet,
183    ThemeColor::ToolDiffAdded,
184    ThemeColor::ToolDiffRemoved,
185    ThemeColor::ToolDiffContext,
186    ThemeColor::SyntaxComment,
187    ThemeColor::SyntaxKeyword,
188    ThemeColor::SyntaxFunction,
189    ThemeColor::SyntaxVariable,
190    ThemeColor::SyntaxString,
191    ThemeColor::SyntaxNumber,
192    ThemeColor::SyntaxType,
193    ThemeColor::SyntaxOperator,
194    ThemeColor::SyntaxPunctuation,
195    ThemeColor::ThinkingOff,
196    ThemeColor::ThinkingMinimal,
197    ThemeColor::ThinkingLow,
198    ThemeColor::ThinkingMedium,
199    ThemeColor::ThinkingHigh,
200    ThemeColor::ThinkingXhigh,
201    ThemeColor::ThinkingMax,
202    ThemeColor::BashMode,
203];
204
205/// All background slots in schema order.
206pub const ALL_BG: [ThemeBg; 6] = [
207    ThemeBg::SelectedBg,
208    ThemeBg::UserMessageBg,
209    ThemeBg::CustomMessageBg,
210    ThemeBg::ToolPendingBg,
211    ThemeBg::ToolSuccessBg,
212    ThemeBg::ToolErrorBg,
213];
214
215/// One resolved color: an 8-bit-per-channel RGB triple.
216///
217/// [`Self::none`] is retained as the reset sentinel for [`ResolvedTheme::from_slots`].
218#[derive(Clone, Copy, Debug, Eq, PartialEq)]
219pub struct Rgb(pub u8, pub u8, pub u8);
220
221impl Rgb {
222    /// The reset sentinel used by the compatibility [`ResolvedTheme::from_slots`] constructor.
223    #[must_use]
224    pub const fn none() -> Self {
225        Self(0, 0, 0)
226    }
227}
228
229#[derive(Clone, Copy, Debug, Eq, PartialEq)]
230enum ResolvedColor {
231    Default,
232    Indexed(u8),
233    Rgb(Rgb),
234}
235
236impl ResolvedColor {
237    const fn rgb(self) -> Rgb {
238        match self {
239            Self::Default => Rgb::none(),
240            Self::Indexed(index) => Rgb(index, index, index),
241            Self::Rgb(rgb) => rgb,
242        }
243    }
244}
245
246/// A fully resolved product theme: ANSI emitters for every slot plus mode.
247///
248/// Cheap to clone via [`Arc`]; the view-model reads it through [`current()`]
249/// inside the `fn`-pointer color hooks.
250#[derive(Clone, Debug)]
251pub struct ResolvedTheme {
252    fg: [ResolvedColor; ALL_FG.len()],
253    bg: [ResolvedColor; ALL_BG.len()],
254    mode: ColorMode,
255    /// Theme display name.
256    pub name: Cow<'static, str>,
257}
258
259impl ResolvedTheme {
260    fn fg_index(color: ThemeColor) -> usize {
261        ALL_FG.iter().position(|c| *c == color).unwrap_or(0)
262    }
263
264    fn bg_index(bg: ThemeBg) -> usize {
265        ALL_BG.iter().position(|b| *b == bg).unwrap_or(0)
266    }
267
268    /// Build from resolved per-slot colors.
269    #[must_use]
270    pub fn from_slots(
271        fg: impl IntoIterator<Item = (ThemeColor, Rgb)>,
272        bg: impl IntoIterator<Item = (ThemeBg, Rgb)>,
273        mode: ColorMode,
274        name: impl Into<Cow<'static, str>>,
275    ) -> Self {
276        Self::from_resolved_slots(
277            fg.into_iter().map(|(slot, rgb)| {
278                let color = if rgb == Rgb::none() {
279                    ResolvedColor::Default
280                } else {
281                    ResolvedColor::Rgb(rgb)
282                };
283                (slot, color)
284            }),
285            bg.into_iter().map(|(slot, rgb)| {
286                let color = if rgb == Rgb::none() {
287                    ResolvedColor::Default
288                } else {
289                    ResolvedColor::Rgb(rgb)
290                };
291                (slot, color)
292            }),
293            mode,
294            name,
295        )
296    }
297
298    fn from_resolved_slots(
299        fg: impl IntoIterator<Item = (ThemeColor, ResolvedColor)>,
300        bg: impl IntoIterator<Item = (ThemeBg, ResolvedColor)>,
301        mode: ColorMode,
302        name: impl Into<Cow<'static, str>>,
303    ) -> Self {
304        let mut fg_arr = [ResolvedColor::Default; ALL_FG.len()];
305        for (color, resolved) in fg {
306            fg_arr[Self::fg_index(color)] = resolved;
307        }
308        let mut bg_arr = [ResolvedColor::Default; ALL_BG.len()];
309        for (bg, resolved) in bg {
310            bg_arr[Self::bg_index(bg)] = resolved;
311        }
312        Self {
313            fg: fg_arr,
314            bg: bg_arr,
315            mode,
316            name: name.into(),
317        }
318    }
319
320    fn from_rgb_arrays(
321        fg: [Rgb; ALL_FG.len()],
322        bg: [Rgb; ALL_BG.len()],
323        mode: ColorMode,
324        name: impl Into<Cow<'static, str>>,
325    ) -> Self {
326        Self {
327            fg: fg.map(ResolvedColor::Rgb),
328            bg: bg.map(ResolvedColor::Rgb),
329            mode,
330            name: name.into(),
331        }
332    }
333
334    /// Active color mode.
335    #[must_use]
336    pub const fn mode(&self) -> ColorMode {
337        self.mode
338    }
339
340    /// Raw RGB for a foreground slot (empty slots return black; check [`Self::is_fg_empty`]).
341    #[must_use]
342    pub fn fg_rgb(&self, color: ThemeColor) -> Rgb {
343        self.fg[Self::fg_index(color)].rgb()
344    }
345
346    /// Raw RGB for a background slot.
347    #[must_use]
348    pub fn bg_rgb(&self, bg: ThemeBg) -> Rgb {
349        self.bg[Self::bg_index(bg)].rgb()
350    }
351
352    /// Whether a foreground slot is empty (resets color).
353    #[must_use]
354    pub fn is_fg_empty(&self, color: ThemeColor) -> bool {
355        self.fg[Self::fg_index(color)] == ResolvedColor::Default
356    }
357
358    /// Whether a background slot is empty.
359    #[must_use]
360    pub fn is_bg_empty(&self, bg: ThemeBg) -> bool {
361        self.bg[Self::bg_index(bg)] == ResolvedColor::Default
362    }
363
364    /// Style `text` with a foreground color, resetting foreground after.
365    ///
366    /// Mirrors reference `Theme.fg`: `\x1b[38..m{text}\x1b[39m`.
367    #[must_use]
368    pub fn fg(&self, color: ThemeColor, text: &str) -> String {
369        let mut out = String::with_capacity(text.len() + 24);
370        self.push_fg(&mut out, self.fg[Self::fg_index(color)]);
371        out.push_str(text);
372        out.push_str("\x1b[39m");
373        out
374    }
375
376    /// Style `text` with a background color, resetting background after.
377    #[must_use]
378    pub fn bg(&self, bg: ThemeBg, text: &str) -> String {
379        let mut out = String::with_capacity(text.len() + 24);
380        self.push_bg(&mut out, self.bg[Self::bg_index(bg)]);
381        out.push_str(text);
382        out.push_str("\x1b[49m");
383        out
384    }
385
386    fn push_fg(&self, out: &mut String, color: ResolvedColor) {
387        match color {
388            ResolvedColor::Default => out.push_str("\x1b[39m"),
389            ResolvedColor::Indexed(index) => {
390                out.push_str("\x1b[38;5;");
391                out.push_str(index.to_string().as_str());
392                out.push('m');
393            }
394            ResolvedColor::Rgb(rgb) => match self.mode {
395                ColorMode::Truecolor => {
396                    out.push_str("\x1b[38;2;");
397                    push_rgb(out, rgb);
398                    out.push('m');
399                }
400                ColorMode::Palette256 => {
401                    out.push_str("\x1b[38;5;");
402                    out.push_str(rgb_to_256(rgb).to_string().as_str());
403                    out.push('m');
404                }
405            },
406        }
407    }
408
409    fn push_bg(&self, out: &mut String, color: ResolvedColor) {
410        match color {
411            ResolvedColor::Default => out.push_str("\x1b[49m"),
412            ResolvedColor::Indexed(index) => {
413                out.push_str("\x1b[48;5;");
414                out.push_str(index.to_string().as_str());
415                out.push('m');
416            }
417            ResolvedColor::Rgb(rgb) => match self.mode {
418                ColorMode::Truecolor => {
419                    out.push_str("\x1b[48;2;");
420                    push_rgb(out, rgb);
421                    out.push('m');
422                }
423                ColorMode::Palette256 => {
424                    out.push_str("\x1b[48;5;");
425                    out.push_str(rgb_to_256(rgb).to_string().as_str());
426                    out.push('m');
427                }
428            },
429        }
430    }
431
432    /// Return the background ANSI prefix string for `bg` (no trailing reset).
433    ///
434    /// Used to build the `Fn(&str) -> String` background applicators that
435    /// pi-tui's `Padded`/`Text` containers accept.
436    #[must_use]
437    pub fn bg_ansi(&self, bg: ThemeBg) -> String {
438        let mut out = String::new();
439        self.push_bg(&mut out, self.bg[Self::bg_index(bg)]);
440        out
441    }
442
443    /// Return the foreground ANSI prefix string for `color` (no trailing reset).
444    #[must_use]
445    pub fn fg_ansi(&self, color: ThemeColor) -> String {
446        let mut out = String::new();
447        self.push_fg(&mut out, self.fg[Self::fg_index(color)]);
448        out
449    }
450}
451
452fn push_rgb(out: &mut String, Rgb(r, g, b): Rgb) {
453    out.push_str(r.to_string().as_str());
454    out.push(';');
455    out.push_str(g.to_string().as_str());
456    out.push(';');
457    out.push_str(b.to_string().as_str());
458}
459
460thread_local! {
461    /// Current theme read by the `fn`-pointer color hooks. `None` ⇒ dark.
462    static CURRENT: RefCell<Option<Arc<ResolvedTheme>>> = const { RefCell::new(None) };
463}
464
465/// Install `theme` as the thread-local current theme for the duration of `f`.
466///
467/// Re-entrant; restores the prior theme on drop. Use this around any render
468/// that builds pi-tui themed components.
469pub fn with_theme<R>(theme: Arc<ResolvedTheme>, f: impl FnOnce() -> R) -> R {
470    let prior = CURRENT.with(|c| c.borrow().clone());
471    CURRENT.with(|c| *c.borrow_mut() = Some(theme));
472    let r = f();
473    CURRENT.with(|c| *c.borrow_mut() = prior);
474    r
475}
476
477/// Permanently install `theme` as this thread's current theme.
478///
479/// Used by the runtime at startup and on `/reload`. Tests prefer
480/// [`with_theme`] for scoped swaps.
481pub fn set_current(theme: Arc<ResolvedTheme>) {
482    CURRENT.with(|c| *c.borrow_mut() = Some(theme));
483}
484
485/// Clone the current thread-local theme (`None` installed ⇒ built-in dark).
486///
487/// Returns an [`Arc`] clone (one atomic op). Called by the `fn`-pointer hooks.
488#[must_use]
489pub fn current() -> Arc<ResolvedTheme> {
490    CURRENT.with(|c| c.borrow().clone()).unwrap_or_else(dark)
491}
492
493// ---------------------------------------------------------------------------
494// fn-pointer color hooks (read the thread-local current theme)
495// ---------------------------------------------------------------------------
496
497/// Build a foreground `fn(&str) -> String` for `color` that resolves against
498/// [`current()`] at call time.
499///
500/// pi-tui theme fields are plain `fn` pointers that cannot capture a runtime
501/// color, so each variant dispatches through the thread-local.
502#[must_use]
503pub fn make_fg(color: ThemeColor) -> fn(&str) -> String {
504    match color {
505        ThemeColor::Accent => |s| current().fg(ThemeColor::Accent, s),
506        ThemeColor::Border => |s| current().fg(ThemeColor::Border, s),
507        ThemeColor::BorderAccent => |s| current().fg(ThemeColor::BorderAccent, s),
508        ThemeColor::BorderMuted => |s| current().fg(ThemeColor::BorderMuted, s),
509        ThemeColor::Success => |s| current().fg(ThemeColor::Success, s),
510        ThemeColor::Error => |s| current().fg(ThemeColor::Error, s),
511        ThemeColor::Warning => |s| current().fg(ThemeColor::Warning, s),
512        ThemeColor::Muted => |s| current().fg(ThemeColor::Muted, s),
513        ThemeColor::Dim => |s| current().fg(ThemeColor::Dim, s),
514        ThemeColor::Text => |s| current().fg(ThemeColor::Text, s),
515        ThemeColor::ThinkingText => |s| current().fg(ThemeColor::ThinkingText, s),
516        ThemeColor::UserMessageText => |s| current().fg(ThemeColor::UserMessageText, s),
517        ThemeColor::CustomMessageText => |s| current().fg(ThemeColor::CustomMessageText, s),
518        ThemeColor::CustomMessageLabel => |s| current().fg(ThemeColor::CustomMessageLabel, s),
519        ThemeColor::ToolTitle => |s| current().fg(ThemeColor::ToolTitle, s),
520        ThemeColor::ToolOutput => |s| current().fg(ThemeColor::ToolOutput, s),
521        ThemeColor::MdHeading => |s| current().fg(ThemeColor::MdHeading, s),
522        ThemeColor::MdLink => |s| current().fg(ThemeColor::MdLink, s),
523        ThemeColor::MdLinkUrl => |s| current().fg(ThemeColor::MdLinkUrl, s),
524        ThemeColor::MdCode => |s| current().fg(ThemeColor::MdCode, s),
525        ThemeColor::MdCodeBlock => |s| current().fg(ThemeColor::MdCodeBlock, s),
526        ThemeColor::MdCodeBlockBorder => |s| current().fg(ThemeColor::MdCodeBlockBorder, s),
527        ThemeColor::MdQuote => |s| current().fg(ThemeColor::MdQuote, s),
528        ThemeColor::MdQuoteBorder => |s| current().fg(ThemeColor::MdQuoteBorder, s),
529        ThemeColor::MdHr => |s| current().fg(ThemeColor::MdHr, s),
530        ThemeColor::MdListBullet => |s| current().fg(ThemeColor::MdListBullet, s),
531        ThemeColor::ToolDiffAdded => |s| current().fg(ThemeColor::ToolDiffAdded, s),
532        ThemeColor::ToolDiffRemoved => |s| current().fg(ThemeColor::ToolDiffRemoved, s),
533        ThemeColor::ToolDiffContext => |s| current().fg(ThemeColor::ToolDiffContext, s),
534        ThemeColor::SyntaxComment => |s| current().fg(ThemeColor::SyntaxComment, s),
535        ThemeColor::SyntaxKeyword => |s| current().fg(ThemeColor::SyntaxKeyword, s),
536        ThemeColor::SyntaxFunction => |s| current().fg(ThemeColor::SyntaxFunction, s),
537        ThemeColor::SyntaxVariable => |s| current().fg(ThemeColor::SyntaxVariable, s),
538        ThemeColor::SyntaxString => |s| current().fg(ThemeColor::SyntaxString, s),
539        ThemeColor::SyntaxNumber => |s| current().fg(ThemeColor::SyntaxNumber, s),
540        ThemeColor::SyntaxType => |s| current().fg(ThemeColor::SyntaxType, s),
541        ThemeColor::SyntaxOperator => |s| current().fg(ThemeColor::SyntaxOperator, s),
542        ThemeColor::SyntaxPunctuation => |s| current().fg(ThemeColor::SyntaxPunctuation, s),
543        ThemeColor::ThinkingOff => |s| current().fg(ThemeColor::ThinkingOff, s),
544        ThemeColor::ThinkingMinimal => |s| current().fg(ThemeColor::ThinkingMinimal, s),
545        ThemeColor::ThinkingLow => |s| current().fg(ThemeColor::ThinkingLow, s),
546        ThemeColor::ThinkingMedium => |s| current().fg(ThemeColor::ThinkingMedium, s),
547        ThemeColor::ThinkingHigh => |s| current().fg(ThemeColor::ThinkingHigh, s),
548        ThemeColor::ThinkingXhigh => |s| current().fg(ThemeColor::ThinkingXhigh, s),
549        ThemeColor::ThinkingMax => |s| current().fg(ThemeColor::ThinkingMax, s),
550        ThemeColor::BashMode => |s| current().fg(ThemeColor::BashMode, s),
551    }
552}
553
554/// Build a markdown theme from [`current()`]. Mirrors `getMarkdownTheme()`.
555#[must_use]
556pub fn markdown_theme() -> MarkdownTheme {
557    MarkdownTheme {
558        heading: make_fg(ThemeColor::MdHeading),
559        link: make_fg(ThemeColor::MdLink),
560        link_url: make_fg(ThemeColor::MdLinkUrl),
561        code: make_fg(ThemeColor::MdCode),
562        code_block: make_fg(ThemeColor::MdCodeBlock),
563        code_block_border: make_fg(ThemeColor::MdCodeBlockBorder),
564        quote: make_fg(ThemeColor::MdQuote),
565        quote_border: make_fg(ThemeColor::MdQuoteBorder),
566        hr: make_fg(ThemeColor::MdHr),
567        list_bullet: make_fg(ThemeColor::MdListBullet),
568        bold,
569        italic,
570        underline,
571        strikethrough,
572        highlight_code: None,
573        code_block_indent: "  ".to_owned(),
574    }
575}
576
577/// Markdown options matching the user-message renderer.
578#[must_use]
579pub fn user_markdown_options() -> MarkdownOptions {
580    MarkdownOptions {
581        preserve_ordered_list_markers: true,
582        preserve_backslash_escapes: true,
583        hyperlinks: false,
584    }
585}
586
587/// Select-list theme from [`current()`]. Mirrors `getSelectListTheme()`.
588#[must_use]
589pub fn select_list_theme() -> SelectListTheme {
590    SelectListTheme {
591        selected_prefix: make_fg(ThemeColor::Accent),
592        selected_text: make_fg(ThemeColor::Accent),
593        description: make_fg(ThemeColor::Muted),
594        scroll_info: make_fg(ThemeColor::Muted),
595        no_match: make_fg(ThemeColor::Muted),
596    }
597}
598
599/// Settings-list theme from [`current()`]. Mirrors `getSettingsListTheme()`.
600///
601/// `cursor` is resolved once (it is a `String`, not a hook).
602#[must_use]
603pub fn settings_list_theme() -> SettingsListTheme {
604    SettingsListTheme {
605        label: label_selected,
606        value: value_selected,
607        description: make_fg(ThemeColor::Dim),
608        cursor: current().fg(ThemeColor::Accent, "→ "),
609        hint: make_fg(ThemeColor::Dim),
610    }
611}
612
613fn label_selected(s: &str, selected: bool) -> String {
614    if selected {
615        current().fg(ThemeColor::Accent, s)
616    } else {
617        s.to_owned()
618    }
619}
620
621fn value_selected(s: &str, selected: bool) -> String {
622    if selected {
623        current().fg(ThemeColor::Accent, s)
624    } else {
625        current().fg(ThemeColor::Muted, s)
626    }
627}
628
629/// Bold text (`\x1b[1m…\x1b[22m`).
630#[must_use]
631pub fn bold(s: &str) -> String {
632    format!("\x1b[1m{s}\x1b[22m")
633}
634
635/// Italic text.
636#[must_use]
637pub fn italic(s: &str) -> String {
638    format!("\x1b[3m{s}\x1b[23m")
639}
640
641/// Underline text.
642#[must_use]
643pub fn underline(s: &str) -> String {
644    format!("\x1b[4m{s}\x1b[24m")
645}
646
647/// Strikethrough text.
648#[must_use]
649pub fn strikethrough(s: &str) -> String {
650    format!("\x1b[9m{s}\x1b[29m")
651}
652
653/// Inverse video.
654#[must_use]
655pub fn inverse(s: &str) -> String {
656    format!("\x1b[7m{s}\x1b[27m")
657}
658
659/// Default text style for assistant body markdown (no decoration).
660#[must_use]
661pub fn default_text_style() -> DefaultTextStyle {
662    DefaultTextStyle::default()
663}
664
665/// Helper to truncate a single line to `width` with an ellipsis.
666#[must_use]
667pub fn truncate_line(text: &str, width: usize, ellipsis: &str) -> String {
668    if width == 0 {
669        return String::new();
670    }
671    if visible_width(text) <= width {
672        return text.to_owned();
673    }
674    truncate_to_width(text, width, ellipsis, false)
675}
676
677// ---------------------------------------------------------------------------
678// 256-color downsampling (ports rgbTo256 from theme.ts)
679// ---------------------------------------------------------------------------
680
681const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
682
683fn closest_cube(value: u8) -> usize {
684    let mut best = 0usize;
685    let mut best_dist = u32::MAX;
686    for (i, c) in CUBE_VALUES.iter().enumerate() {
687        let d = (i32::from(value) - i32::from(*c)).unsigned_abs();
688        if d < best_dist {
689            best_dist = d;
690            best = i;
691        }
692    }
693    best
694}
695
696fn color_distance(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> u32 {
697    let dr = i32::from(r1) - i32::from(r2);
698    let dg = i32::from(g1) - i32::from(g2);
699    let db = i32::from(b1) - i32::from(b2);
700    let weighted = (dr * dr * 299 + dg * dg * 587 + db * db * 114) / 1000;
701    weighted.try_into().unwrap_or(u32::MAX)
702}
703
704/// Map an RGB triple to the nearest 256-color palette index.
705#[must_use]
706pub fn rgb_to_256(rgb: Rgb) -> u8 {
707    let Rgb(r, g, b) = rgb;
708    let r_idx = closest_cube(r);
709    let g_idx = closest_cube(g);
710    let b_idx = closest_cube(b);
711    let cube_r = CUBE_VALUES[r_idx];
712    let cube_g = CUBE_VALUES[g_idx];
713    let cube_b = CUBE_VALUES[b_idx];
714    let cube_index = 16 + 36 * r_idx + 6 * g_idx + b_idx;
715    let cube_dist = color_distance(r, g, b, cube_r, cube_g, cube_b);
716
717    let gray = u8::try_from((u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1000)
718        .unwrap_or(255);
719    let gray_idx = closest_gray(gray);
720    let gray_value = GRAY_VALUES[gray_idx];
721    let gray_index = 232 + gray_idx;
722    let gray_dist = color_distance(r, g, b, gray_value, gray_value, gray_value);
723
724    let max_c = r.max(g).max(b);
725    let min_c = r.min(g).min(b);
726    let spread = u32::from(max_c) - u32::from(min_c);
727
728    if spread < 10 && gray_dist < cube_dist {
729        u8::try_from(gray_index).unwrap_or(255)
730    } else {
731        u8::try_from(cube_index).unwrap_or(255)
732    }
733}
734
735const GRAY_VALUES: [u8; 24] = gray_values();
736const fn gray_values() -> [u8; 24] {
737    let mut out = [0u8; 24];
738    let mut i = 0u8;
739    while i < 24 {
740        // i ∈ 0..24 ⇒ 8 + i*10 ∈ 8..=238, always fits in u8.
741        out[i as usize] = 8 + i * 10;
742        i += 1;
743    }
744    out
745}
746
747fn closest_gray(gray: u8) -> usize {
748    let mut best = 0usize;
749    let mut best_dist = u32::MAX;
750    for (i, v) in GRAY_VALUES.iter().enumerate() {
751        let d = (i32::from(gray) - i32::from(*v)).unsigned_abs();
752        if d < best_dist {
753            best_dist = d;
754            best = i;
755        }
756    }
757    best
758}
759
760// ---------------------------------------------------------------------------
761// Built-in dark / light themes (resolved interns)
762// ---------------------------------------------------------------------------
763
764const fn rgb(r: u8, g: u8, b: u8) -> Rgb {
765    Rgb(r, g, b)
766}
767
768/// Built-in dark theme (interned). Mirrors `dark.json` with vars resolved.
769#[must_use]
770pub fn dark() -> Arc<ResolvedTheme> {
771    DARK_CLONE.clone()
772}
773
774/// Built-in light theme (interned). Mirrors `light.json` with vars resolved.
775#[must_use]
776pub fn light() -> Arc<ResolvedTheme> {
777    LIGHT_INTERN.clone()
778}
779
780static DARK_CLONE: LazyLock<Arc<ResolvedTheme>> = LazyLock::new(|| {
781    Arc::new(ResolvedTheme::from_rgb_arrays(
782        dark_fg(),
783        dark_bg(),
784        ColorMode::Truecolor,
785        Cow::Borrowed("dark"),
786    ))
787});
788
789static LIGHT_INTERN: LazyLock<Arc<ResolvedTheme>> = LazyLock::new(|| {
790    Arc::new(ResolvedTheme::from_rgb_arrays(
791        light_fg(),
792        light_bg(),
793        ColorMode::Truecolor,
794        Cow::Borrowed("light"),
795    ))
796});
797
798// ---------------------------------------------------------------------------
799// JSON loading + validation
800// ---------------------------------------------------------------------------
801
802/// Errors produced while loading or validating a theme JSON document.
803#[derive(Debug, thiserror::Error)]
804pub enum ThemeError {
805    /// A required color slot was missing.
806    #[error("missing required color slot: {0}")]
807    MissingColor(String),
808    /// A color value was neither a hex string, a variable name, nor empty.
809    #[error("invalid color value for `{slot}`: {value}")]
810    InvalidColor {
811        /// Slot name.
812        slot: String,
813        /// Raw value that failed to parse.
814        value: String,
815    },
816    /// A variable referenced an undefined name.
817    #[error("unknown theme variable reference: {0}")]
818    UnknownVar(String),
819    /// A variable chain was cyclic.
820    #[error("circular theme variable reference: {0}")]
821    CircularVar(String),
822    /// `vars`/`colors` map held a non-string/non-number color.
823    #[error("theme `{field}` must be a string or integer")]
824    InvalidFieldType {
825        /// `vars` or `colors`.
826        field: &'static str,
827    },
828    /// The top-level document was not a JSON object.
829    #[error("theme json must be an object")]
830    NotAnObject,
831    /// The theme `name` was missing.
832    #[error("theme json missing required field: name")]
833    MissingName,
834    /// The theme name contained a `/`.
835    #[error("invalid theme name \"{0}\": names cannot contain '/'")]
836    InvalidName(String),
837    /// The named theme file was not found in any theme directory.
838    #[error("theme not found: {0}")]
839    NotFound(String),
840    /// I/O error reading the file.
841    #[error("io error: {0}")]
842    Io(#[from] std::io::Error),
843    /// JSON parse error.
844    #[error("json error: {0}")]
845    Json(#[from] serde_json::Error),
846}
847
848/// Parsed (not yet resolved) theme document.
849#[derive(Clone, Debug)]
850pub struct ThemeJson {
851    name: String,
852    vars: Vec<(String, ColorValue)>,
853    colors: Vec<(String, ColorValue)>,
854}
855
856#[derive(Clone, Debug)]
857enum ColorValue {
858    Hex(String),
859    Var(String),
860    Empty,
861    Indexed(u8),
862}
863
864impl ThemeJson {
865    /// Parse a theme document from JSON text.
866    ///
867    /// # Errors
868    ///
869    /// Returns [`ThemeError`] when the document is malformed.
870    pub fn parse(json: &str) -> Result<Self, ThemeError> {
871        let value: serde_json::Value = serde_json::from_str(json)?;
872        Self::from_value(&value)
873    }
874
875    /// Parse from an already-deserialized JSON value.
876    ///
877    /// # Errors
878    ///
879    /// Returns [`ThemeError`] when the value is malformed.
880    pub fn from_value(value: &serde_json::Value) -> Result<Self, ThemeError> {
881        let obj = value.as_object().ok_or(ThemeError::NotAnObject)?;
882        let name = obj
883            .get("name")
884            .and_then(serde_json::Value::as_str)
885            .ok_or(ThemeError::MissingName)?
886            .to_owned();
887        if name.contains('/') {
888            return Err(ThemeError::InvalidName(name));
889        }
890        let vars = parse_color_map(obj.get("vars"), "vars")?;
891        let colors_obj = obj
892            .get("colors")
893            .and_then(|v| v.as_object())
894            .ok_or_else(|| ThemeError::MissingColor("colors".to_owned()))?;
895        let mut colors = Vec::new();
896        for slot in REQUIRED_COLORS {
897            let val = colors_obj
898                .get(*slot)
899                .ok_or_else(|| ThemeError::MissingColor((*slot).to_owned()))?;
900            let cv = parse_color_value(val).ok_or_else(|| ThemeError::InvalidColor {
901                slot: (*slot).to_owned(),
902                value: val.to_string(),
903            })?;
904            colors.push(((*slot).to_owned(), cv));
905        }
906        // thinkingMax is optional → falls back to thinkingXhigh.
907        if !colors.iter().any(|(k, _)| k == "thinkingMax")
908            && let Some((_, x)) = colors.iter().find(|(k, _)| k == "thinkingXhigh")
909        {
910            colors.push(("thinkingMax".to_owned(), x.clone()));
911        }
912        Ok(Self { name, vars, colors })
913    }
914
915    /// Theme display name.
916    #[must_use]
917    pub fn name(&self) -> &str {
918        &self.name
919    }
920
921    /// Resolve into an interned [`Arc<ResolvedTheme>`].
922    ///
923    /// # Errors
924    ///
925    /// Returns [`ThemeError`] on unknown/circular variable references or bad hex.
926    pub fn resolve(&self, mode: ColorMode) -> Result<Arc<ResolvedTheme>, ThemeError> {
927        Ok(Arc::new(self.resolve_owned(mode)?))
928    }
929
930    /// Resolve into an owned [`ResolvedTheme`].
931    ///
932    /// # Errors
933    ///
934    /// Returns [`ThemeError`] on resolution failure.
935    pub fn resolve_owned(&self, mode: ColorMode) -> Result<ResolvedTheme, ThemeError> {
936        let mut fg = [(ThemeColor::Accent, ResolvedColor::Default); ALL_FG.len()];
937        for (i, (slot_enum, slot_name)) in ALL_FG_SLOTS.iter().enumerate() {
938            let value = self
939                .colors
940                .iter()
941                .find(|(k, _)| k == *slot_name)
942                .map(|(_, v)| v.clone())
943                .ok_or_else(|| ThemeError::MissingColor((*slot_name).to_owned()))?;
944            let resolved = resolve_value(&value, &self.vars, slot_name)?;
945            fg[i] = (*slot_enum, resolved);
946        }
947        let mut bg = [(ThemeBg::SelectedBg, ResolvedColor::Default); ALL_BG.len()];
948        for (i, (slot_enum, slot_name)) in ALL_BG_SLOTS.iter().enumerate() {
949            let value = self
950                .colors
951                .iter()
952                .find(|(k, _)| k == *slot_name)
953                .map(|(_, v)| v.clone())
954                .ok_or_else(|| ThemeError::MissingColor((*slot_name).to_owned()))?;
955            let resolved = resolve_value(&value, &self.vars, slot_name)?;
956            bg[i] = (*slot_enum, resolved);
957        }
958        Ok(ResolvedTheme::from_resolved_slots(
959            fg,
960            bg,
961            mode,
962            self.name.clone(),
963        ))
964    }
965}
966
967fn parse_color_map(
968    value: Option<&serde_json::Value>,
969    field: &'static str,
970) -> Result<Vec<(String, ColorValue)>, ThemeError> {
971    let Some(obj) = value.and_then(|v| v.as_object()) else {
972        return Ok(Vec::new());
973    };
974    let mut out = Vec::with_capacity(obj.len());
975    for (k, v) in obj {
976        let cv = parse_color_value(v).ok_or(ThemeError::InvalidFieldType { field })?;
977        out.push((k.clone(), cv));
978    }
979    Ok(out)
980}
981
982fn parse_color_value(v: &serde_json::Value) -> Option<ColorValue> {
983    if let Some(s) = v.as_str() {
984        if s.is_empty() {
985            return Some(ColorValue::Empty);
986        }
987        if let Some(rest) = s.strip_prefix('#') {
988            if rest.len() == 6 && rest.chars().all(|c| c.is_ascii_hexdigit()) {
989                return Some(ColorValue::Hex(s.to_owned()));
990            }
991            return None;
992        }
993        return Some(ColorValue::Var(s.to_owned()));
994    }
995    if let Some(n) = v.as_i64().filter(|n| (0..=255).contains(n)) {
996        return Some(ColorValue::Indexed(u8::try_from(n).unwrap_or(0)));
997    }
998    None
999}
1000
1001fn resolve_value(
1002    value: &ColorValue,
1003    vars: &[(String, ColorValue)],
1004    slot: &str,
1005) -> Result<ResolvedColor, ThemeError> {
1006    let mut visited: Vec<String> = Vec::new();
1007    let mut current = value.clone();
1008    loop {
1009        match current {
1010            ColorValue::Empty => return Ok(ResolvedColor::Default),
1011            ColorValue::Hex(s) => {
1012                return hex_to_rgb(&s).map(ResolvedColor::Rgb).ok_or_else(|| {
1013                    ThemeError::InvalidColor {
1014                        slot: slot.to_owned(),
1015                        value: s.clone(),
1016                    }
1017                });
1018            }
1019            ColorValue::Indexed(index) => return Ok(ResolvedColor::Indexed(index)),
1020            ColorValue::Var(name) => {
1021                if visited.iter().any(|v| v == &name) {
1022                    return Err(ThemeError::CircularVar(name));
1023                }
1024                visited.push(name.clone());
1025                let Some((_, next)) = vars.iter().find(|(k, _)| k == &name) else {
1026                    return Err(ThemeError::UnknownVar(name));
1027                };
1028                current = next.clone();
1029            }
1030        }
1031    }
1032}
1033
1034fn hex_to_rgb(hex: &str) -> Option<Rgb> {
1035    let rest = hex.strip_prefix('#')?;
1036    if rest.len() != 6 {
1037        return None;
1038    }
1039    let r = u8::from_str_radix(&rest[0..2], 16).ok()?;
1040    let g = u8::from_str_radix(&rest[2..4], 16).ok()?;
1041    let b = u8::from_str_radix(&rest[4..6], 16).ok()?;
1042    Some(Rgb(r, g, b))
1043}
1044
1045/// Load a theme by name from the configured theme directories.
1046///
1047/// Searches built-in (`get_themes_dir`) then custom (`get_custom_themes_dir`).
1048/// `"dark"` and `"light"` resolve to the built-in interns without disk access.
1049///
1050/// # Errors
1051///
1052/// See [`ThemeError`] variants.
1053pub fn load_by_name(name: &str, mode: ColorMode) -> Result<Arc<ResolvedTheme>, ThemeError> {
1054    if name == "dark" {
1055        return Ok(dark());
1056    }
1057    if name == "light" {
1058        return Ok(light());
1059    }
1060    let builtin_path = config::get_themes_dir().join(format!("{name}.json"));
1061    let custom_path = config::get_custom_themes_dir().join(format!("{name}.json"));
1062    let path = if builtin_path.exists() {
1063        builtin_path
1064    } else if custom_path.exists() {
1065        custom_path
1066    } else {
1067        return Err(ThemeError::NotFound(name.to_owned()));
1068    };
1069    let text = std::fs::read_to_string(&path)?;
1070    ThemeJson::parse(&text)?.resolve(mode)
1071}
1072
1073/// Load a theme, falling back to the built-in dark theme on any error.
1074///
1075/// This is the safe entry point used by the view-model so a corrupt theme
1076/// never breaks rendering.
1077#[must_use]
1078pub fn load_or_dark(name: &str, mode: ColorMode) -> Arc<ResolvedTheme> {
1079    load_by_name(name, mode).unwrap_or_else(|_| dark())
1080}
1081
1082// --- slot name tables ------------------------------------------------------
1083
1084const REQUIRED_COLORS: &[&str] = &[
1085    "accent",
1086    "border",
1087    "borderAccent",
1088    "borderMuted",
1089    "success",
1090    "error",
1091    "warning",
1092    "muted",
1093    "dim",
1094    "text",
1095    "thinkingText",
1096    "selectedBg",
1097    "userMessageBg",
1098    "userMessageText",
1099    "customMessageBg",
1100    "customMessageText",
1101    "customMessageLabel",
1102    "toolPendingBg",
1103    "toolSuccessBg",
1104    "toolErrorBg",
1105    "toolTitle",
1106    "toolOutput",
1107    "mdHeading",
1108    "mdLink",
1109    "mdLinkUrl",
1110    "mdCode",
1111    "mdCodeBlock",
1112    "mdCodeBlockBorder",
1113    "mdQuote",
1114    "mdQuoteBorder",
1115    "mdHr",
1116    "mdListBullet",
1117    "toolDiffAdded",
1118    "toolDiffRemoved",
1119    "toolDiffContext",
1120    "syntaxComment",
1121    "syntaxKeyword",
1122    "syntaxFunction",
1123    "syntaxVariable",
1124    "syntaxString",
1125    "syntaxNumber",
1126    "syntaxType",
1127    "syntaxOperator",
1128    "syntaxPunctuation",
1129    "thinkingOff",
1130    "thinkingMinimal",
1131    "thinkingLow",
1132    "thinkingMedium",
1133    "thinkingHigh",
1134    "thinkingXhigh",
1135    "bashMode",
1136];
1137
1138const ALL_FG_SLOTS: &[(ThemeColor, &str)] = &[
1139    (ThemeColor::Accent, "accent"),
1140    (ThemeColor::Border, "border"),
1141    (ThemeColor::BorderAccent, "borderAccent"),
1142    (ThemeColor::BorderMuted, "borderMuted"),
1143    (ThemeColor::Success, "success"),
1144    (ThemeColor::Error, "error"),
1145    (ThemeColor::Warning, "warning"),
1146    (ThemeColor::Muted, "muted"),
1147    (ThemeColor::Dim, "dim"),
1148    (ThemeColor::Text, "text"),
1149    (ThemeColor::ThinkingText, "thinkingText"),
1150    (ThemeColor::UserMessageText, "userMessageText"),
1151    (ThemeColor::CustomMessageText, "customMessageText"),
1152    (ThemeColor::CustomMessageLabel, "customMessageLabel"),
1153    (ThemeColor::ToolTitle, "toolTitle"),
1154    (ThemeColor::ToolOutput, "toolOutput"),
1155    (ThemeColor::MdHeading, "mdHeading"),
1156    (ThemeColor::MdLink, "mdLink"),
1157    (ThemeColor::MdLinkUrl, "mdLinkUrl"),
1158    (ThemeColor::MdCode, "mdCode"),
1159    (ThemeColor::MdCodeBlock, "mdCodeBlock"),
1160    (ThemeColor::MdCodeBlockBorder, "mdCodeBlockBorder"),
1161    (ThemeColor::MdQuote, "mdQuote"),
1162    (ThemeColor::MdQuoteBorder, "mdQuoteBorder"),
1163    (ThemeColor::MdHr, "mdHr"),
1164    (ThemeColor::MdListBullet, "mdListBullet"),
1165    (ThemeColor::ToolDiffAdded, "toolDiffAdded"),
1166    (ThemeColor::ToolDiffRemoved, "toolDiffRemoved"),
1167    (ThemeColor::ToolDiffContext, "toolDiffContext"),
1168    (ThemeColor::SyntaxComment, "syntaxComment"),
1169    (ThemeColor::SyntaxKeyword, "syntaxKeyword"),
1170    (ThemeColor::SyntaxFunction, "syntaxFunction"),
1171    (ThemeColor::SyntaxVariable, "syntaxVariable"),
1172    (ThemeColor::SyntaxString, "syntaxString"),
1173    (ThemeColor::SyntaxNumber, "syntaxNumber"),
1174    (ThemeColor::SyntaxType, "syntaxType"),
1175    (ThemeColor::SyntaxOperator, "syntaxOperator"),
1176    (ThemeColor::SyntaxPunctuation, "syntaxPunctuation"),
1177    (ThemeColor::ThinkingOff, "thinkingOff"),
1178    (ThemeColor::ThinkingMinimal, "thinkingMinimal"),
1179    (ThemeColor::ThinkingLow, "thinkingLow"),
1180    (ThemeColor::ThinkingMedium, "thinkingMedium"),
1181    (ThemeColor::ThinkingHigh, "thinkingHigh"),
1182    (ThemeColor::ThinkingXhigh, "thinkingXhigh"),
1183    (ThemeColor::ThinkingMax, "thinkingMax"),
1184    (ThemeColor::BashMode, "bashMode"),
1185];
1186
1187const ALL_BG_SLOTS: &[(ThemeBg, &str)] = &[
1188    (ThemeBg::SelectedBg, "selectedBg"),
1189    (ThemeBg::UserMessageBg, "userMessageBg"),
1190    (ThemeBg::CustomMessageBg, "customMessageBg"),
1191    (ThemeBg::ToolPendingBg, "toolPendingBg"),
1192    (ThemeBg::ToolSuccessBg, "toolSuccessBg"),
1193    (ThemeBg::ToolErrorBg, "toolErrorBg"),
1194];
1195
1196// --- built-in resolved color tables ----------------------------------------
1197
1198const fn dark_fg() -> [Rgb; 46] {
1199    [
1200        rgb(138, 190, 183), // accent
1201        rgb(95, 135, 255),  // border
1202        rgb(0, 215, 255),   // borderAccent
1203        rgb(80, 80, 80),    // borderMuted
1204        rgb(181, 189, 104), // success
1205        rgb(204, 102, 102), // error
1206        rgb(255, 255, 0),   // warning
1207        rgb(128, 128, 128), // muted
1208        rgb(102, 102, 102), // dim
1209        rgb(212, 212, 212), // text
1210        rgb(128, 128, 128), // thinkingText
1211        rgb(212, 212, 212), // userMessageText
1212        rgb(212, 212, 212), // customMessageText
1213        rgb(149, 117, 205), // customMessageLabel
1214        rgb(212, 212, 212), // toolTitle
1215        rgb(128, 128, 128), // toolOutput
1216        rgb(240, 198, 116), // mdHeading
1217        rgb(129, 162, 190), // mdLink
1218        rgb(102, 102, 102), // mdLinkUrl
1219        rgb(138, 190, 183), // mdCode
1220        rgb(181, 189, 104), // mdCodeBlock
1221        rgb(128, 128, 128), // mdCodeBlockBorder
1222        rgb(128, 128, 128), // mdQuote
1223        rgb(128, 128, 128), // mdQuoteBorder
1224        rgb(128, 128, 128), // mdHr
1225        rgb(138, 190, 183), // mdListBullet
1226        rgb(181, 189, 104), // toolDiffAdded
1227        rgb(204, 102, 102), // toolDiffRemoved
1228        rgb(128, 128, 128), // toolDiffContext
1229        rgb(106, 153, 85),  // syntaxComment
1230        rgb(86, 156, 214),  // syntaxKeyword
1231        rgb(220, 220, 170), // syntaxFunction
1232        rgb(156, 220, 254), // syntaxVariable
1233        rgb(206, 145, 120), // syntaxString
1234        rgb(181, 206, 168), // syntaxNumber
1235        rgb(78, 201, 176),  // syntaxType
1236        rgb(212, 212, 212), // syntaxOperator
1237        rgb(212, 212, 212), // syntaxPunctuation
1238        rgb(80, 80, 80),    // thinkingOff
1239        rgb(110, 110, 110), // thinkingMinimal
1240        rgb(95, 135, 175),  // thinkingLow
1241        rgb(129, 162, 190), // thinkingMedium
1242        rgb(178, 148, 187), // thinkingHigh
1243        rgb(209, 131, 232), // thinkingXhigh
1244        rgb(255, 95, 255),  // thinkingMax
1245        rgb(181, 189, 104), // bashMode
1246    ]
1247}
1248
1249const fn dark_bg() -> [Rgb; 6] {
1250    [
1251        rgb(58, 58, 74), // selectedBg
1252        rgb(52, 53, 65), // userMessageBg
1253        rgb(45, 40, 56), // customMessageBg
1254        rgb(40, 40, 50), // toolPendingBg
1255        rgb(40, 50, 40), // toolSuccessBg
1256        rgb(60, 40, 40), // toolErrorBg
1257    ]
1258}
1259
1260const fn light_fg() -> [Rgb; 46] {
1261    [
1262        rgb(90, 128, 128),  // accent
1263        rgb(84, 125, 167),  // border
1264        rgb(90, 128, 128),  // borderAccent
1265        rgb(176, 176, 176), // borderMuted
1266        rgb(88, 132, 88),   // success
1267        rgb(170, 85, 85),   // error
1268        rgb(154, 115, 38),  // warning
1269        rgb(108, 108, 108), // muted
1270        rgb(118, 118, 118), // dim
1271        rgb(31, 35, 40),    // text
1272        rgb(108, 108, 108), // thinkingText
1273        rgb(31, 35, 40),    // userMessageText
1274        rgb(31, 35, 40),    // customMessageText
1275        rgb(126, 87, 194),  // customMessageLabel
1276        rgb(31, 35, 40),    // toolTitle
1277        rgb(108, 108, 108), // toolOutput
1278        rgb(154, 115, 38),  // mdHeading
1279        rgb(84, 125, 167),  // mdLink
1280        rgb(118, 118, 118), // mdLinkUrl
1281        rgb(90, 128, 128),  // mdCode
1282        rgb(88, 132, 88),   // mdCodeBlock
1283        rgb(108, 108, 108), // mdCodeBlockBorder
1284        rgb(108, 108, 108), // mdQuote
1285        rgb(108, 108, 108), // mdQuoteBorder
1286        rgb(108, 108, 108), // mdHr
1287        rgb(88, 132, 88),   // mdListBullet
1288        rgb(88, 132, 88),   // toolDiffAdded
1289        rgb(170, 85, 85),   // toolDiffRemoved
1290        rgb(108, 108, 108), // toolDiffContext
1291        rgb(0, 128, 0),     // syntaxComment
1292        rgb(0, 0, 255),     // syntaxKeyword
1293        rgb(121, 94, 38),   // syntaxFunction
1294        rgb(0, 16, 128),    // syntaxVariable
1295        rgb(163, 21, 21),   // syntaxString
1296        rgb(9, 134, 88),    // syntaxNumber
1297        rgb(38, 127, 153),  // syntaxType
1298        rgb(0, 0, 0),       // syntaxOperator
1299        rgb(0, 0, 0),       // syntaxPunctuation
1300        rgb(176, 176, 176), // thinkingOff
1301        rgb(118, 118, 118), // thinkingMinimal
1302        rgb(84, 125, 167),  // thinkingLow
1303        rgb(90, 128, 128),  // thinkingMedium
1304        rgb(135, 95, 135),  // thinkingHigh
1305        rgb(139, 0, 139),   // thinkingXhigh
1306        rgb(175, 0, 95),    // thinkingMax
1307        rgb(88, 132, 88),   // bashMode
1308    ]
1309}
1310
1311const fn light_bg() -> [Rgb; 6] {
1312    [
1313        rgb(208, 208, 224), // selectedBg
1314        rgb(232, 232, 232), // userMessageBg
1315        rgb(237, 231, 246), // customMessageBg
1316        rgb(232, 232, 240), // toolPendingBg
1317        rgb(232, 240, 232), // toolSuccessBg
1318        rgb(240, 232, 232), // toolErrorBg
1319    ]
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325
1326    type TestResult = Result<(), String>;
1327
1328    fn parsed_theme(overrides: &[(&str, serde_json::Value)]) -> Result<ThemeJson, String> {
1329        let mut colors = serde_json::Map::new();
1330        for slot in REQUIRED_COLORS {
1331            colors.insert((*slot).to_owned(), serde_json::json!("#010203"));
1332        }
1333        for (slot, value) in overrides {
1334            colors.insert((*slot).to_owned(), value.clone());
1335        }
1336        ThemeJson::from_value(&serde_json::json!({
1337            "name": "test",
1338            "colors": colors,
1339        }))
1340        .map_err(|error| format!("test theme should parse: {error}"))
1341    }
1342
1343    #[test]
1344    fn dark_accent_resolves() {
1345        let th = dark();
1346        assert_eq!(th.fg_rgb(ThemeColor::Accent), Rgb(138, 190, 183));
1347    }
1348
1349    #[test]
1350    fn builtin_literal_black_remains_a_color() {
1351        let theme = light();
1352        assert!(!theme.is_fg_empty(ThemeColor::SyntaxOperator));
1353        assert_eq!(
1354            theme.fg_ansi(ThemeColor::SyntaxOperator),
1355            "\x1b[38;2;0;0;0m"
1356        );
1357    }
1358
1359    #[test]
1360    fn json_black_is_distinct_from_default() -> TestResult {
1361        let theme = parsed_theme(&[
1362            ("accent", serde_json::json!("#000000")),
1363            ("muted", serde_json::json!("")),
1364            ("selectedBg", serde_json::json!("#000000")),
1365            ("toolErrorBg", serde_json::json!("")),
1366        ])?
1367        .resolve_owned(ColorMode::Truecolor)
1368        .map_err(|error| format!("test theme should resolve: {error}"))?;
1369
1370        assert_eq!(theme.fg_rgb(ThemeColor::Accent), Rgb(0, 0, 0));
1371        assert!(!theme.is_fg_empty(ThemeColor::Accent));
1372        assert_eq!(theme.fg_ansi(ThemeColor::Accent), "\x1b[38;2;0;0;0m");
1373        assert!(theme.is_fg_empty(ThemeColor::Muted));
1374        assert_eq!(theme.fg_ansi(ThemeColor::Muted), "\x1b[39m");
1375
1376        assert_eq!(theme.bg_rgb(ThemeBg::SelectedBg), Rgb(0, 0, 0));
1377        assert!(!theme.is_bg_empty(ThemeBg::SelectedBg));
1378        assert_eq!(theme.bg_ansi(ThemeBg::SelectedBg), "\x1b[48;2;0;0;0m");
1379        assert!(theme.is_bg_empty(ThemeBg::ToolErrorBg));
1380        assert_eq!(theme.bg_ansi(ThemeBg::ToolErrorBg), "\x1b[49m");
1381        Ok(())
1382    }
1383
1384    #[test]
1385    fn json_indexed_colors_emit_exact_sequences_in_every_mode() -> TestResult {
1386        let parsed = parsed_theme(&[
1387            ("accent", serde_json::json!(17)),
1388            ("selectedBg", serde_json::json!(231)),
1389        ])?;
1390
1391        for mode in [ColorMode::Truecolor, ColorMode::Palette256] {
1392            let theme = parsed
1393                .resolve_owned(mode)
1394                .map_err(|error| format!("test theme should resolve: {error}"))?;
1395            assert_eq!(theme.fg_ansi(ThemeColor::Accent), "\x1b[38;5;17m");
1396            assert_eq!(theme.bg_ansi(ThemeBg::SelectedBg), "\x1b[48;5;231m");
1397            assert_eq!(theme.fg_rgb(ThemeColor::Accent), Rgb(17, 17, 17));
1398            assert_eq!(theme.bg_rgb(ThemeBg::SelectedBg), Rgb(231, 231, 231));
1399        }
1400        Ok(())
1401    }
1402}