Skip to main content

vtcode_tui/core_tui/session/
styling.rs

1use anstyle::{AnsiColor, Color as AnsiColorEnum, RgbColor};
2use ratatui::prelude::*;
3
4use crate::config::constants::ui;
5use crate::ui::tui::{
6    style::{ratatui_color_from_ansi, ratatui_style_from_inline},
7    types::{InlineMessageKind, InlineTextStyle, InlineTheme},
8};
9
10use super::message::MessageLine;
11
12fn mix(color: RgbColor, target: RgbColor, ratio: f64) -> RgbColor {
13    let ratio = ratio.clamp(ui::THEME_MIX_RATIO_MIN, ui::THEME_MIX_RATIO_MAX);
14    let blend = |c: u8, t: u8| -> u8 {
15        let c = c as f64;
16        let t = t as f64;
17        ((c + (t - c) * ratio).round()).clamp(ui::THEME_BLEND_CLAMP_MIN, ui::THEME_BLEND_CLAMP_MAX)
18            as u8
19    };
20
21    RgbColor(
22        blend(color.0, target.0),
23        blend(color.1, target.1),
24        blend(color.2, target.2),
25    )
26}
27
28pub fn normalize_tool_name(tool_name: &str) -> &'static str {
29    match tool_name.to_lowercase().as_str() {
30        "grep" | "rg" | "ripgrep" | "grep_file" | "search" | "find" | "ag" => "search",
31        "list" | "ls" | "dir" | "list_files" => "list",
32        "read" | "cat" | "file" | "read_file" => "read",
33        "write" | "edit" | "save" | "insert" | "edit_file" => "write",
34        "run" | "command" | "bash" | "sh" => "run",
35        _ => "other",
36    }
37}
38
39/// Get the inline style for a tool based on its normalized name.
40/// Shared by both `SessionStyles` and standalone rendering contexts.
41pub fn tool_inline_style_for(tool_name: &str, theme: &InlineTheme) -> InlineTextStyle {
42    let normalized_name = normalize_tool_name(tool_name);
43    let mut style = InlineTextStyle::default().bold();
44
45    style.color = match normalized_name {
46        "read" => Some(AnsiColor::Cyan.into()),
47        "list" => Some(AnsiColor::Green.into()),
48        "search" => Some(AnsiColor::Cyan.into()),
49        "write" => Some(AnsiColor::Magenta.into()),
50        "run" => Some(AnsiColor::Red.into()),
51        "git" | "version_control" => Some(AnsiColor::Cyan.into()),
52        _ => theme.tool_accent.or(theme.primary).or(theme.foreground),
53    };
54
55    style
56}
57
58/// Styling utilities for the Session UI
59pub struct SessionStyles {
60    theme: InlineTheme,
61}
62
63impl SessionStyles {
64    pub fn new(theme: InlineTheme) -> Self {
65        Self { theme }
66    }
67
68    #[allow(dead_code)]
69    pub fn theme(&self) -> &InlineTheme {
70        &self.theme
71    }
72
73    pub fn set_theme(&mut self, theme: InlineTheme) {
74        self.theme = theme;
75    }
76
77    /// Get the modal list highlight style
78    pub fn modal_list_highlight_style(&self) -> Style {
79        let mut style = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
80        if let Some(primary) = self.theme.primary.or(self.theme.foreground) {
81            style = style.fg(ratatui_color_from_ansi(primary));
82        }
83        style
84    }
85
86    /// Get the inline style for a tool based on its name
87    #[allow(dead_code)]
88    pub fn tool_inline_style(&self, tool_name: &str) -> InlineTextStyle {
89        tool_inline_style_for(tool_name, &self.theme)
90    }
91
92    /// Get the tool border style
93    pub fn tool_border_style(&self) -> InlineTextStyle {
94        self.border_inline_style()
95    }
96
97    /// Get the default style with both foreground and background from the theme.
98    /// Painting the theme background ensures readability regardless of terminal
99    /// color scheme (e.g. a light theme on a dark terminal no longer appears blank).
100    pub fn default_style(&self) -> Style {
101        let mut style = Style::default();
102        if let Some(background) = self.theme.background.map(ratatui_color_from_ansi) {
103            style = style.bg(background);
104        }
105        if let Some(foreground) = self.theme.foreground.map(ratatui_color_from_ansi) {
106            style = style.fg(foreground);
107        }
108        style
109    }
110
111    /// Get the default inline style (for tests and inline conversions)
112    #[allow(dead_code)]
113    pub fn default_inline_style(&self) -> InlineTextStyle {
114        InlineTextStyle {
115            color: self.theme.foreground,
116            ..InlineTextStyle::default()
117        }
118    }
119
120    /// Get the accent inline style
121    pub fn accent_inline_style(&self) -> InlineTextStyle {
122        InlineTextStyle {
123            color: self.theme.primary.or(self.theme.foreground),
124            ..InlineTextStyle::default()
125        }
126    }
127
128    /// Get the accent style
129    pub fn accent_style(&self) -> Style {
130        ratatui_style_from_inline(&self.accent_inline_style(), self.theme.foreground)
131    }
132
133    pub fn transcript_link_style(&self) -> Style {
134        let style = InlineTextStyle {
135            color: self
136                .theme
137                .tool_accent
138                .or(self.theme.primary)
139                .or(self.theme.foreground),
140            ..InlineTextStyle::default()
141        };
142        ratatui_style_from_inline(&style, self.theme.foreground)
143    }
144
145    /// Get the border inline style
146    pub fn border_inline_style(&self) -> InlineTextStyle {
147        InlineTextStyle {
148            color: self.theme.secondary.or(self.theme.foreground),
149            ..InlineTextStyle::default()
150        }
151    }
152
153    /// Get the border style (dimmed)
154    pub fn border_style(&self) -> Style {
155        self.dimmed_border_style(true)
156    }
157
158    /// Get a border style with configurable boldness.
159    /// When `suppress_bold` is true, the BOLD modifier is removed — useful for
160    /// info/error/warning block borders that should appear subtle.
161    pub fn dimmed_border_style(&self, suppress_bold: bool) -> Style {
162        let mut style =
163            ratatui_style_from_inline(&self.border_inline_style(), self.theme.foreground)
164                .add_modifier(Modifier::DIM);
165        if suppress_bold {
166            style = style.remove_modifier(Modifier::BOLD);
167        }
168        style
169    }
170
171    pub fn input_background_style(&self) -> Style {
172        let mut style = self.default_style();
173        let Some(background) = self.theme.background else {
174            return style;
175        };
176
177        let resolved = match (background, self.theme.foreground) {
178            (AnsiColorEnum::Rgb(bg), Some(AnsiColorEnum::Rgb(fg))) => {
179                AnsiColorEnum::Rgb(mix(bg, fg, ui::THEME_INPUT_BACKGROUND_MIX_RATIO))
180            }
181            (color, _) => color,
182        };
183
184        style = style.bg(ratatui_color_from_ansi(resolved));
185        style
186    }
187
188    /// Get the prefix style for a message line
189    pub fn prefix_style(&self, line: &MessageLine) -> InlineTextStyle {
190        let fallback = self.text_fallback(line.kind).or(self.theme.foreground);
191
192        let color = line
193            .segments
194            .iter()
195            .find_map(|segment| segment.style.color)
196            .or(fallback);
197
198        InlineTextStyle {
199            color,
200            ..InlineTextStyle::default()
201        }
202    }
203
204    /// Get the fallback text color for a message kind
205    pub fn text_fallback(&self, kind: InlineMessageKind) -> Option<AnsiColorEnum> {
206        match kind {
207            // Assistant content should be legible and clearly distinct from subdued PTY output.
208            InlineMessageKind::Agent => self.theme.foreground.or(self.theme.primary),
209            InlineMessageKind::Policy => self.theme.primary.or(self.theme.foreground),
210            InlineMessageKind::User => self.theme.secondary.or(self.theme.foreground),
211            InlineMessageKind::Tool | InlineMessageKind::Error => {
212                self.theme.primary.or(self.theme.foreground)
213            }
214            InlineMessageKind::Pty => self
215                .theme
216                .pty_body
217                .or(self.theme.tool_body)
218                .or(self.theme.foreground),
219            InlineMessageKind::Info => self.theme.foreground,
220            InlineMessageKind::Warning => Some(AnsiColor::Red.into()),
221        }
222    }
223
224    /// Get the message divider style
225    pub fn message_divider_style(&self, kind: InlineMessageKind) -> Style {
226        let mut style = InlineTextStyle::default();
227        if kind == InlineMessageKind::User {
228            style.color = self.theme.primary.or(self.theme.foreground);
229        } else {
230            style.color = self.text_fallback(kind).or(self.theme.foreground);
231        }
232        let resolved = ratatui_style_from_inline(&style, self.theme.foreground);
233        resolved.add_modifier(Modifier::DIM)
234    }
235}