vtcode_ui/tui/core_tui/session/
styling.rs1use 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
36pub(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
50pub 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 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 pub fn tool_inline_style(&self, tool_name: &str) -> InlineTextStyle {
80 tool_inline_style_for(tool_name, &self.theme)
81 }
82
83 pub fn tool_border_style(&self) -> InlineTextStyle {
85 self.border_inline_style()
86 }
87
88 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 pub(crate) fn default_inline_style(&self) -> InlineTextStyle {
104 InlineTextStyle {
105 color: self.theme.foreground,
106 ..InlineTextStyle::default()
107 }
108 }
109
110 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 pub(crate) fn accent_style(&self) -> Style {
120 ratatui_style_from_inline(&self.accent_inline_style(), self.theme.foreground)
121 }
122
123 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 fn border_inline_style(&self) -> InlineTextStyle {
142 InlineTextStyle {
143 color: self.theme.secondary.or(self.theme.foreground),
144 ..InlineTextStyle::default()
145 }
146 }
147
148 pub(crate) fn border_style(&self) -> Style {
150 self.dimmed_border_style(true)
151 }
152
153 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 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 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 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 pub(crate) fn text_fallback(&self, kind: InlineMessageKind) -> Option<AnsiColorEnum> {
224 match kind {
225 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 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 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}