Skip to main content

vtcode_ui/tui/core_tui/session/
styling.rs

1use anstyle::{AnsiColor, Color as AnsiColorEnum, RgbColor};
2use ratatui::prelude::*;
3use vtcode_config::constants::tools;
4
5use crate::tui::config::constants::ui;
6use crate::tui::ui::tui::{
7    style::{ratatui_color_from_ansi, ratatui_style_from_inline},
8    types::{InlineMessageKind, InlineTextStyle, InlineTheme},
9};
10
11use super::message::MessageLine;
12
13fn mix(color: RgbColor, target: RgbColor, ratio: f32) -> RgbColor {
14    let ratio = ratio.clamp(ui::THEME_MIX_RATIO_MIN, ui::THEME_MIX_RATIO_MAX);
15    let blend = |c: u8, t: u8| -> u8 {
16        let c = c as f32;
17        let t = t as f32;
18        ((c + (t - c) * ratio).round()).clamp(ui::THEME_BLEND_CLAMP_MIN, ui::THEME_BLEND_CLAMP_MAX) as u8
19    };
20
21    RgbColor(blend(color.0, target.0), blend(color.1, target.1), blend(color.2, target.2))
22}
23
24fn normalize_tool_name(tool_name: &str) -> &'static str {
25    match tool_name.to_lowercase().as_str() {
26        "grep" | "rg" | "ripgrep" | "search" | "find" | "ag" | tools::GREP_FILE => "search",
27        "list" | "ls" | "dir" | tools::LIST_FILES => "list",
28        "read" | "cat" | "file" | tools::READ_FILE => "read",
29        "write" | "edit" | "save" | "insert" | tools::EDIT_FILE => "write",
30        "git" | "version_control" => "git",
31        "run" | "command" | "bash" | "sh" | "ran" => "run",
32        _ => "other",
33    }
34}
35
36/// Get the inline style for a tool based on its normalized name.
37/// Shared by both `SessionStyles` and standalone rendering contexts.
38pub(crate) fn tool_inline_style_for(tool_name: &str, theme: &InlineTheme) -> InlineTextStyle {
39    let normalized_name = normalize_tool_name(tool_name);
40    let mut style = InlineTextStyle::default().bold();
41
42    style.color = match normalized_name {
43        "read" | "list" | "search" | "git" => theme.primary.or(theme.tool_accent).or(theme.foreground),
44        _ => theme.tool_accent.or(theme.primary).or(theme.foreground),
45    };
46
47    style
48}
49
50/// Styling utilities for the Session UI
51pub struct SessionStyles {
52    theme: InlineTheme,
53}
54
55impl SessionStyles {
56    pub(crate) fn new(theme: InlineTheme) -> Self {
57        Self { theme }
58    }
59
60    pub fn theme(&self) -> &InlineTheme {
61        &self.theme
62    }
63
64    pub(crate) fn set_theme(&mut self, theme: InlineTheme) {
65        self.theme = theme;
66    }
67
68    /// Get the modal list highlight style (Select-style: primary fg, no bg change)
69    pub(crate) fn modal_list_highlight_style(&self) -> Style {
70        let accent = self.theme.primary.or(self.theme.tool_accent).or(self.theme.foreground);
71        let mut style = self.default_style().add_modifier(Modifier::BOLD);
72        if let Some(accent) = accent {
73            style = style.fg(ratatui_color_from_ansi(accent));
74        }
75        style
76    }
77
78    /// Get the inline style for a tool based on its name
79    pub fn tool_inline_style(&self, tool_name: &str) -> InlineTextStyle {
80        tool_inline_style_for(tool_name, &self.theme)
81    }
82
83    /// Get the tool border style
84    pub fn tool_border_style(&self) -> InlineTextStyle {
85        self.border_inline_style()
86    }
87
88    /// Get the default style with both foreground and background from the theme.
89    /// Painting the theme background ensures readability regardless of terminal
90    /// color scheme (e.g. a light theme on a dark terminal no longer appears blank).
91    pub(crate) fn default_style(&self) -> Style {
92        let mut style = Style::default();
93        if let Some(background) = self.theme.background.map(ratatui_color_from_ansi) {
94            style = style.bg(background);
95        }
96        if let Some(foreground) = self.theme.foreground.map(ratatui_color_from_ansi) {
97            style = style.fg(foreground);
98        }
99        style
100    }
101
102    /// Get the default inline style (for tests and inline conversions)
103    pub(crate) fn default_inline_style(&self) -> InlineTextStyle {
104        InlineTextStyle {
105            color: self.theme.foreground,
106            ..InlineTextStyle::default()
107        }
108    }
109
110    /// Get the accent inline style
111    pub(crate) fn accent_inline_style(&self) -> InlineTextStyle {
112        InlineTextStyle {
113            color: self.theme.primary.or(self.theme.foreground),
114            ..InlineTextStyle::default()
115        }
116    }
117
118    /// Get the accent style
119    pub(crate) fn accent_style(&self) -> Style {
120        ratatui_style_from_inline(&self.accent_inline_style(), self.theme.foreground)
121    }
122
123    /// Get the warning style (amber token, falling back to the theme foreground).
124    ///
125    /// Reuses the canonical [`Self::text_fallback`] chain for `Warning` so this
126    /// style cannot drift from the semantic warning color.
127    pub(crate) fn warning_style(&self) -> Style {
128        let color = self.text_fallback(InlineMessageKind::Warning);
129        ratatui_style_from_inline(&InlineTextStyle { color, ..InlineTextStyle::default() }, self.theme.foreground)
130    }
131
132    pub(crate) fn transcript_link_style(&self) -> Style {
133        let style = InlineTextStyle {
134            color: self.theme.tool_accent.or(self.theme.primary).or(self.theme.foreground),
135            ..InlineTextStyle::default()
136        };
137        ratatui_style_from_inline(&style, self.theme.foreground)
138    }
139
140    /// Get the border inline style
141    fn border_inline_style(&self) -> InlineTextStyle {
142        InlineTextStyle {
143            color: self.theme.secondary.or(self.theme.foreground),
144            ..InlineTextStyle::default()
145        }
146    }
147
148    /// Get the border style (dimmed)
149    pub(crate) fn border_style(&self) -> Style {
150        self.dimmed_border_style(true)
151    }
152
153    /// Muted foreground for secondary text (context labels, subtitles, hints).
154    ///
155    /// Deliberately an explicit color — theme `secondary` with a `Gray`
156    /// fallback, mirroring the `muted` slot of `input_styles_from_theme` —
157    /// instead of `Modifier::DIM`: DIM renders as SGR 2, which several
158    /// terminals attenuate to near-invisible, and ratatui's `Cell::set_style`
159    /// only ever *inserts* modifiers, so a DIM painted as an area background
160    /// sticks to every glyph drawn on top of it and mutes otherwise-bright
161    /// text. Same no-DIM rule the diff gutter styles follow.
162    pub(crate) fn muted_text_style(&self) -> Style {
163        let color = self
164            .theme
165            .secondary
166            .or(self.theme.foreground)
167            .map(ratatui_color_from_ansi)
168            .unwrap_or(Color::Gray);
169        self.default_style().fg(color)
170    }
171
172    /// Get a border style with configurable boldness.
173    /// When `suppress_bold` is true, the BOLD modifier is removed — useful for
174    /// subtle block borders that should appear dimmed.
175    pub(crate) fn dimmed_border_style(&self, suppress_bold: bool) -> Style {
176        let mut style =
177            ratatui_style_from_inline(&self.border_inline_style(), self.theme.foreground).add_modifier(Modifier::DIM);
178        if suppress_bold {
179            style = style.remove_modifier(Modifier::BOLD);
180        }
181        style
182    }
183
184    pub(crate) fn input_background_style(&self) -> Style {
185        let mut style = self.default_style();
186        let Some(background) = self.theme.background else {
187            return style;
188        };
189
190        let resolved = match (background, self.theme.foreground) {
191            (AnsiColorEnum::Rgb(bg), Some(AnsiColorEnum::Rgb(fg))) => {
192                AnsiColorEnum::Rgb(mix(bg, fg, ui::THEME_INPUT_BACKGROUND_MIX_RATIO))
193            }
194            (color, _) => color,
195        };
196
197        style = style.bg(ratatui_color_from_ansi(resolved));
198        style
199    }
200
201    /// Preserve theme foreground contrast while using the composer tint where
202    /// it remains readable. Some light themes sit close to the AA floor.
203    pub(crate) fn sticky_prompt_style(&self) -> Style {
204        let style = self.input_background_style();
205        if let (Some(Color::Rgb(fr, fg, fb)), Some(Color::Rgb(br, bg, bb))) = (style.fg, style.bg)
206            && crate::theme::contrast_ratio(RgbColor(fr, fg, fb), RgbColor(br, bg, bb)) < ui::THEME_MIN_CONTRAST_RATIO
207        {
208            return self.default_style();
209        }
210        style
211    }
212
213    /// Get the prefix style for a message line
214    pub(crate) fn prefix_style(&self, line: &MessageLine) -> InlineTextStyle {
215        let fallback = self.text_fallback(line.kind).or(self.theme.foreground);
216
217        let color = line.segments.iter().find_map(|segment| segment.style.color).or(fallback);
218
219        InlineTextStyle { color, ..InlineTextStyle::default() }
220    }
221
222    /// Get the fallback text color for a message kind
223    pub(crate) fn text_fallback(&self, kind: InlineMessageKind) -> Option<AnsiColorEnum> {
224        match kind {
225            // Assistant content should be legible and clearly distinct from subdued PTY output.
226            InlineMessageKind::Agent => self.theme.foreground.or(self.theme.primary),
227            InlineMessageKind::Policy => self.theme.primary.or(self.theme.foreground),
228            InlineMessageKind::User => self.theme.secondary.or(self.theme.foreground),
229            InlineMessageKind::Tool => self.theme.primary.or(self.theme.foreground),
230            InlineMessageKind::Error => self.theme.error.or(Some(AnsiColor::Red.into())).or(self.theme.foreground),
231            InlineMessageKind::Warning => {
232                self.theme.warning.or(Some(AnsiColor::Yellow.into())).or(self.theme.foreground)
233            }
234            InlineMessageKind::Pty => self.theme.pty_body.or(self.theme.tool_body).or(self.theme.foreground),
235            InlineMessageKind::Info => self.theme.foreground,
236        }
237    }
238
239    /// Get the message divider style
240    ///
241    /// Section dividers (`User` turn breaks and `Agent` synthesis after tool
242    /// work) share one quiet prose language: muted `secondary` border hue +
243    /// `DIM`, never bold or background. Full-width shape keeps the break
244    /// glanceable while the muted tone avoids clutter.
245    pub(crate) fn message_divider_style(&self, _kind: InlineMessageKind) -> Style {
246        self.dimmed_border_style(true)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use anstyle::Color as AnsiColorEnum;
253    use ratatui::style::Color;
254
255    use super::*;
256
257    #[test]
258    fn warning_style_prefers_explicit_theme_warning() {
259        let theme = InlineTheme {
260            warning: Some(AnsiColorEnum::Rgb(RgbColor(0xAB, 0xCD, 0xEF))),
261            foreground: Some(AnsiColorEnum::Rgb(RgbColor(0x11, 0x22, 0x33))),
262            ..InlineTheme::default()
263        };
264
265        assert_eq!(
266            SessionStyles::new(theme).warning_style().fg,
267            Some(Color::Rgb(0xAB, 0xCD, 0xEF)),
268            "explicit warning token must win"
269        );
270    }
271
272    #[test]
273    fn warning_style_falls_back_to_amber_not_foreground() {
274        // With no warning token, the canonical `text_fallback(Warning)` chain
275        // supplies amber — not the theme foreground.
276        let theme = InlineTheme {
277            foreground: Some(AnsiColorEnum::Rgb(RgbColor(0x11, 0x22, 0x33))),
278            ..InlineTheme::default()
279        };
280
281        assert_eq!(
282            SessionStyles::new(theme).warning_style().fg,
283            Some(Color::Yellow),
284            "missing warning token must fall back to amber"
285        );
286    }
287}