Skip to main content

vtcode_tui/core_tui/session/
styling.rs

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