Skip to main content

vtcode_commons/ui_protocol/
style.rs

1//! Style and theming types that depend on `anstyle`.
2
3use std::sync::Arc;
4
5use anstyle::{Color as AnsiColorEnum, Effects, Style as AnsiStyle};
6
7use super::InlineMessageKind;
8
9/// Inline text styling with foreground/background color and text effects.
10#[derive(Clone, Debug, Default, PartialEq)]
11pub struct InlineTextStyle {
12    pub color: Option<AnsiColorEnum>,
13    pub bg_color: Option<AnsiColorEnum>,
14    pub effects: Effects,
15}
16
17impl InlineTextStyle {
18    #[must_use]
19    pub fn with_color(mut self, color: Option<AnsiColorEnum>) -> Self {
20        self.color = color;
21        self
22    }
23
24    #[must_use]
25    pub fn with_bg_color(mut self, color: Option<AnsiColorEnum>) -> Self {
26        self.bg_color = color;
27        self
28    }
29
30    #[must_use]
31    pub fn merge_color(mut self, fallback: Option<AnsiColorEnum>) -> Self {
32        if self.color.is_none() {
33            self.color = fallback;
34        }
35        self
36    }
37
38    #[must_use]
39    pub fn merge_bg_color(mut self, fallback: Option<AnsiColorEnum>) -> Self {
40        if self.bg_color.is_none() {
41            self.bg_color = fallback;
42        }
43        self
44    }
45
46    #[must_use]
47    pub fn bold(mut self) -> Self {
48        self.effects |= Effects::BOLD;
49        self
50    }
51
52    #[must_use]
53    pub fn italic(mut self) -> Self {
54        self.effects |= Effects::ITALIC;
55        self
56    }
57
58    #[must_use]
59    pub fn underline(mut self) -> Self {
60        self.effects |= Effects::UNDERLINE;
61        self
62    }
63
64    #[must_use]
65    pub fn dim(mut self) -> Self {
66        self.effects |= Effects::DIMMED;
67        self
68    }
69
70    #[must_use]
71    pub fn to_ansi_style(&self, fallback: Option<AnsiColorEnum>) -> AnsiStyle {
72        let mut style = AnsiStyle::new();
73        if let Some(color) = self.color.or(fallback) {
74            style = style.fg_color(Some(color));
75        }
76        if let Some(bg) = self.bg_color {
77            style = style.bg_color(Some(bg));
78        }
79        if self.effects.contains(Effects::BOLD) {
80            style = style.bold();
81        }
82        if self.effects.contains(Effects::ITALIC) {
83            style = style.italic();
84        }
85        if self.effects.contains(Effects::UNDERLINE) {
86            style = style.underline();
87        }
88        if self.effects.contains(Effects::DIMMED) {
89            style = style.dimmed();
90        }
91        style
92    }
93}
94
95/// A styled text segment with shared style.
96#[derive(Clone, Debug, Default)]
97pub struct InlineSegment {
98    pub text: String,
99    pub style: Arc<InlineTextStyle>,
100}
101
102/// One transcript line carried by a batched append.
103///
104/// Resume and rewind reload the transcript as one batch so the viewport paints
105/// the final state once instead of scrolling through intermediate frames.
106#[derive(Clone, Debug)]
107pub struct InlineTranscriptLine {
108    pub kind: InlineMessageKind,
109    pub segments: Vec<InlineSegment>,
110}
111
112/// A clickable link target inside a transcript line.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub enum InlineLinkTarget {
115    Url(String),
116}
117
118/// Byte-range inside a line that is a clickable link.
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct InlineLinkRange {
121    pub start: usize,
122    pub end: usize,
123    pub target: InlineLinkTarget,
124}
125
126/// Resolved theme colors for inline rendering.
127#[derive(Clone, Debug, Default)]
128pub struct InlineTheme {
129    pub foreground: Option<AnsiColorEnum>,
130    pub background: Option<AnsiColorEnum>,
131    pub primary: Option<AnsiColorEnum>,
132    pub secondary: Option<AnsiColorEnum>,
133    pub tool_accent: Option<AnsiColorEnum>,
134    pub tool_body: Option<AnsiColorEnum>,
135    pub pty_body: Option<AnsiColorEnum>,
136    pub error: Option<AnsiColorEnum>,
137    pub warning: Option<AnsiColorEnum>,
138}
139
140// ---------------------------------------------------------------------------
141// List / modal presentation tones
142// ---------------------------------------------------------------------------
143
144/// Semantic tone for list badges, values, and modal status strips.
145///
146/// Renderers map each tone onto theme styles; callers pick the tone, never a
147/// raw color, so every surface stays theme-consistent and WCAG-checked.
148#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
149pub enum InlineTone {
150    #[default]
151    Neutral,
152    Accent,
153    Success,
154    Warning,
155    Danger,
156    /// Live/current selection marker (the active model, the current value).
157    Current,
158}
159
160/// A short-lived status message shown inside a list modal (footer strip).
161///
162/// One status is kept at a time; a new action overwrites the previous status.
163#[derive(Clone, Debug, PartialEq, Eq)]
164pub struct InlineStatus {
165    pub tone: InlineTone,
166    pub message: String,
167}
168
169impl InlineStatus {
170    #[must_use]
171    pub fn new(tone: InlineTone, message: impl Into<String>) -> Self {
172        Self { tone, message: message.into() }
173    }
174
175    #[must_use]
176    pub fn success(message: impl Into<String>) -> Self {
177        Self::new(InlineTone::Success, message)
178    }
179
180    #[must_use]
181    pub fn warning(message: impl Into<String>) -> Self {
182        Self::new(InlineTone::Warning, message)
183    }
184
185    #[must_use]
186    pub fn error(message: impl Into<String>) -> Self {
187        Self::new(InlineTone::Danger, message)
188    }
189
190    #[must_use]
191    pub fn info(message: impl Into<String>) -> Self {
192        Self::new(InlineTone::Accent, message)
193    }
194}
195
196// ---------------------------------------------------------------------------
197// Header context types
198// ---------------------------------------------------------------------------
199
200/// Status-badge tone used in header status indicators.
201#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
202pub enum InlineHeaderStatusTone {
203    #[default]
204    Ready,
205    Warning,
206    Error,
207}
208
209/// A labelled status badge for the header bar.
210#[derive(Clone, Debug, Default, PartialEq, Eq)]
211pub struct InlineHeaderStatusBadge {
212    pub text: String,
213    pub tone: InlineHeaderStatusTone,
214}
215
216/// A compact pill badge rendered in the header.
217#[derive(Clone, Debug, Default, PartialEq)]
218pub struct InlineHeaderBadge {
219    pub text: String,
220    pub style: InlineTextStyle,
221    pub full_background: bool,
222}
223
224/// A title + content highlight block in the header.
225#[derive(Clone, Debug, Default, PartialEq, Eq)]
226pub struct InlineHeaderHighlight {
227    pub title: String,
228    pub lines: Vec<String>,
229}
230
231/// Session metadata displayed in the inline header.
232#[derive(Clone, Debug)]
233pub struct InlineHeaderContext {
234    pub app_name: String,
235    pub provider: String,
236    pub model: String,
237    pub context_window_size: Option<usize>,
238    pub version: String,
239    pub search_tools: Option<InlineHeaderStatusBadge>,
240    pub persistent_memory: Option<InlineHeaderStatusBadge>,
241    pub pr_review: Option<InlineHeaderStatusBadge>,
242    pub git: String,
243    pub reasoning: String,
244    pub reasoning_stage: Option<String>,
245    /// Configured native OpenAI `service_tier` (`Tier: <name>`), when set.
246    /// Rendered in the header summary only when present; `None` hides it.
247    pub service_tier: Option<String>,
248    pub workspace_trust: String,
249    pub tools: String,
250    pub mcp: String,
251    pub primary_agent: Option<String>,
252    pub primary_agent_color: Option<String>,
253    pub highlights: Vec<InlineHeaderHighlight>,
254    pub subagent_badges: Vec<InlineHeaderBadge>,
255}
256
257impl Default for InlineHeaderContext {
258    fn default() -> Self {
259        let version = env!("CARGO_PKG_VERSION").to_string();
260        Self {
261            // Keep in sync with `vtcode-config::constants::app::DISPLAY_NAME`.
262            // `vtcode-commons` cannot depend on `vtcode-config` (config depends
263            // on commons), so the product name is duplicated here. Covered by
264            // the `header_placeholder_app_name_matches_product` ratchet in
265            // `vtcode-ui`.
266            app_name: "VT Code".to_string(),
267            provider: "Provider: unavailable".to_string(),
268            model: "Model: unavailable".to_string(),
269            context_window_size: None,
270            version,
271            search_tools: None,
272            persistent_memory: None,
273            pr_review: None,
274            git: "git: unavailable".to_string(),
275            reasoning: "unavailable".to_string(),
276            reasoning_stage: None,
277            service_tier: None,
278            workspace_trust: "Trust: unavailable".to_string(),
279            tools: "Tools: unavailable".to_string(),
280            mcp: "MCP: unavailable".to_string(),
281            primary_agent: None,
282            primary_agent_color: None,
283            highlights: Vec::new(),
284            subagent_badges: Vec::new(),
285        }
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Conversion helpers
291// ---------------------------------------------------------------------------
292
293fn convert_ansi_color(color: AnsiColorEnum) -> Option<AnsiColorEnum> {
294    Some(match color {
295        AnsiColorEnum::Ansi(ansi) => AnsiColorEnum::Ansi(ansi),
296        AnsiColorEnum::Ansi256(value) => AnsiColorEnum::Ansi256(value),
297        AnsiColorEnum::Rgb(rgb) => AnsiColorEnum::Rgb(rgb),
298    })
299}
300
301fn convert_style_color(style: &AnsiStyle) -> Option<AnsiColorEnum> {
302    style.get_fg_color().and_then(convert_ansi_color)
303}
304
305fn convert_style_bg_color(style: &AnsiStyle) -> Option<AnsiColorEnum> {
306    style.get_bg_color().and_then(convert_ansi_color)
307}
308
309/// Convert an `anstyle::Style` to an [`InlineTextStyle`].
310pub fn convert_style(style: AnsiStyle) -> InlineTextStyle {
311    InlineTextStyle {
312        color: convert_style_color(&style),
313        bg_color: convert_style_bg_color(&style),
314        effects: style.get_effects(),
315    }
316}
317
318/// Build an [`InlineTheme`] from individual theme colour fields.
319pub fn theme_from_color_fields(
320    foreground: AnsiColorEnum,
321    background: AnsiColorEnum,
322    primary: AnsiStyle,
323    secondary: AnsiStyle,
324    tool: AnsiStyle,
325    tool_detail: AnsiStyle,
326    pty_output: AnsiStyle,
327    error: AnsiStyle,
328    warning: AnsiStyle,
329) -> InlineTheme {
330    InlineTheme {
331        foreground: convert_ansi_color(foreground),
332        background: convert_ansi_color(background),
333        primary: convert_style_color(&primary),
334        secondary: convert_style_color(&secondary),
335        tool_accent: convert_style_color(&tool),
336        tool_body: convert_style_color(&tool_detail),
337        pty_body: convert_style_color(&pty_output),
338        error: convert_style_color(&error),
339        warning: convert_style_color(&warning),
340    }
341}