Skip to main content

vtcode_config/constants/
ui.rs

1pub const TOOL_OUTPUT_MODE_COMPACT: &str = "compact";
2pub const TOOL_OUTPUT_MODE_FULL: &str = "full";
3pub const DEFAULT_INLINE_VIEWPORT_ROWS: u16 = 16;
4pub const DEFAULT_REASONING_VISIBLE: bool = true;
5pub const MODAL_MIN_WIDTH: u16 = 36;
6pub const MODAL_MIN_HEIGHT: u16 = 16;
7pub const MODAL_INSTRUCTIONS_BULLET: &str = "•";
8pub const INLINE_HEADER_HEIGHT: u16 = 3;
9pub const INLINE_INPUT_PADDING_HORIZONTAL: u16 = 1;
10pub const INLINE_INPUT_PADDING_VERTICAL: u16 = 1;
11pub const INLINE_INPUT_MAX_LINES: usize = 10;
12/// Transcript bottom padding rows. Live value:
13/// `vtcode_ui::tui::config::constants::ui::INLINE_TRANSCRIPT_BOTTOM_PADDING`
14/// with `effective_transcript_bottom_padding`; keep in sync.
15pub const INLINE_TRANSCRIPT_BOTTOM_PADDING: u16 = 2;
16pub const INLINE_PREVIEW_ELLIPSIS: &str = "…";
17pub const INLINE_PASTE_COLLAPSE_LINE_THRESHOLD: usize = 10;
18pub const HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS: usize = 48;
19pub const INLINE_AGENT_MESSAGE_LEFT_PADDING: &str = " ";
20pub const INLINE_AGENT_QUOTE_PREFIX: &str = " •";
21pub const INLINE_USER_MESSAGE_DIVIDER_SYMBOL: &str = "─";
22
23pub const INLINE_BLOCK_HORIZONTAL: &str = "─";
24/// Shared inline separator between metadata segments (`a • b • c`).
25///
26/// Single source for every `" • "` join so spacing stays consistent across
27/// headers, modals, and slash-command detail lines.
28pub const META_BULLET_SEPARATOR: &str = " • ";
29pub const MODAL_LIST_HIGHLIGHT_FULL: &str = "┃ ";
30pub const MODAL_LIST_SUMMARY_NO_MATCHES: &str = "No matches";
31pub const MODAL_LIST_SUMMARY_RESET_HINT: &str = "Press Esc to reset";
32pub const MODAL_LIST_NO_RESULTS_MESSAGE: &str = "No matching options";
33pub const HEADER_VERSION_PROMPT: &str = "> ";
34pub const HEADER_VERSION_PREFIX: &str = "VT Code";
35pub const HEADER_VERSION_LEFT_DELIMITER: &str = "(";
36pub const HEADER_VERSION_RIGHT_DELIMITER: &str = ")";
37pub const HEADER_PROVIDER_PREFIX: &str = "Provider: ";
38pub const HEADER_MODEL_PREFIX: &str = "Model: ";
39pub const HEADER_SERVICE_TIER_PREFIX: &str = "Tier: ";
40pub const HEADER_REASONING_PREFIX: &str = "";
41pub const HEADER_TRUST_PREFIX: &str = "Trust: ";
42pub const HEADER_TOOLS_PREFIX: &str = "Tools: ";
43pub const HEADER_MCP_PREFIX: &str = "MCP: ";
44pub const HEADER_GIT_PREFIX: &str = "git: ";
45pub const HEADER_GIT_CLEAN_SUFFIX: &str = "✓";
46pub const HEADER_GIT_DIRTY_SUFFIX: &str = "*";
47pub const HEADER_UNKNOWN_PLACEHOLDER: &str = "unavailable";
48/// Shortcut hints shared by the bootstrap and follow-up placeholders.
49///
50/// Defined once so the two cannot drift apart. The composer paints this on a
51/// single clipped line, so the most useful hints lead and trailing ones fall
52/// off first on narrow terminals.
53///
54/// Each key must match a live binding: `@`/`/` open the file and slash
55/// palettes, Enter submits, `Tab` queues while a turn runs, `Shift+Tab` cycles
56/// to the previous agent, and `Ctrl+R` opens the history picker.
57macro_rules! chat_input_shortcut_hint {
58    () => {
59        "@files /commands • Enter run • Tab queue • Shift+Tab agent • Ctrl+R history"
60    };
61}
62/// Empty-composer prompt before the first turn.
63pub const CHAT_INPUT_PLACEHOLDER_BOOTSTRAP: &str = concat!("Type a request… ", chat_input_shortcut_hint!());
64/// Empty-composer prompt after at least one turn.
65pub const CHAT_INPUT_PLACEHOLDER_FOLLOW_UP: &str = concat!("Continue… ", chat_input_shortcut_hint!());
66pub const CHAT_INPUT_PLACEHOLDER_INTERRUPTED: &str =
67    "Interrupted · What should VT Code do instead? • Esc cancel • Ctrl+C interrupt • Ctrl+D exit";
68pub const WELCOME_SLASH_COMMAND_LIMIT: usize = 6;
69pub const WELCOME_SLASH_COMMAND_PREFIX: &str = "/";
70pub const WELCOME_SLASH_COMMAND_INTRO: &str = "";
71pub const WELCOME_SLASH_COMMAND_INDENT: &str = "  ";
72pub const PLAN_BLOCK_TITLE: &str = "TODOs";
73pub const PLAN_STATUS_EMPTY: &str = "No TODOs";
74pub(crate) const STATUS_LINE_REFRESH_INTERVAL_MS: u64 = 1000;
75pub(crate) const STATUS_LINE_COMMAND_TIMEOUT_MS: u64 = 200;
76
77// Agent/Mode color constants.
78//
79// Mode badges resolve through one canonical token per mode. Most modes use a
80// **standard ANSI hue name** (green/blue/magenta/...), which the design system
81// (`vtcode-ui`) maps to a theme-appropriate variant at render time: the
82// brighter `Light*` ANSI variant on dark terminals, the base variant on light
83// terminals. Build is the exception: it uses a fixed hex token that renders
84// identically on both appearances.
85//
86// `AGENT_HUE_NAMES` mirrors the variant table in `vtcode-ui`'s design color
87// resolver, which owns the actual RGB values.
88const AGENT_HUE_NAMES: &[&str] = &["red", "green", "blue", "magenta", "yellow", "cyan"];
89
90/// Build agent color - sage `#73a18e` (calm, implementation tone).
91pub const AGENT_COLOR_BUILD: &str = "#73a18e";
92/// Auto agent hue - green (autonomy and forward progress).
93pub const AGENT_COLOR_AUTO: &str = "green";
94/// Plan agent hue - blue (thoughtful, planning tone).
95pub const AGENT_COLOR_PLAN: &str = "blue";
96/// Duck agent hue - magenta (soft, discussion-focused).
97pub const AGENT_COLOR_DUCK: &str = "magenta";
98
99/// Canonical primary-agent mode -> color token mapping (single source of
100/// truth). `subagents.rs` assigns the color to each built-in spec from here.
101const AGENT_MODE_HUE: &[(&str, &str)] = &[
102    ("build", AGENT_COLOR_BUILD),
103    ("auto", AGENT_COLOR_AUTO),
104    ("plan", AGENT_COLOR_PLAN),
105    ("duck", AGENT_COLOR_DUCK),
106];
107
108/// Resolve a primary-agent mode name to its canonical color token
109/// (e.g. `"build"` -> `"#73a18e"`, `"plan"` -> `"blue"`).
110pub fn agent_mode_hue(mode: &str) -> Option<&'static str> {
111    AGENT_MODE_HUE.iter().find(|(m, _)| *m == mode).map(|(_, h)| *h)
112}
113
114// TUI timing, tick, and viewport limits live in
115// `vtcode_ui::tui::config::constants::ui` — the only reader. Do not redeclare
116// them here: shadowed copies drift silently (see `no_shadowed_tui_constants`).
117
118// Theme and color constants
119pub const THEME_MIN_CONTRAST_RATIO: f32 = 4.5;
120/// Luminance where white and black text reach the same WCAG contrast ratio;
121/// drives the filled-control foreground choice (`filled_text`).
122pub const THEME_FILLED_TEXT_LUMINANCE_CROSSOVER: f32 = 0.179;
123
124pub const THEME_FOREGROUND_LIGHTEN_RATIO: f32 = 0.25;
125pub const THEME_SECONDARY_LIGHTEN_RATIO: f32 = 0.2;
126pub const THEME_MIX_RATIO: f32 = 0.35;
127pub const THEME_TOOL_BODY_MIX_RATIO: f32 = 0.35;
128pub const THEME_TOOL_BODY_LIGHTEN_RATIO: f32 = 0.2;
129pub const THEME_RESPONSE_COLOR_LIGHTEN_RATIO: f32 = 0.15;
130pub const THEME_INPUT_BACKGROUND_MIX_RATIO: f32 = 0.08;
131pub const THEME_USER_COLOR_LIGHTEN_RATIO: f32 = 0.2;
132pub const THEME_SECONDARY_USER_COLOR_LIGHTEN_RATIO: f32 = 0.4;
133pub const THEME_PRIMARY_STATUS_LIGHTEN_RATIO: f32 = 0.35;
134pub const THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO: f32 = 0.5;
135pub const THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO: f32 = 0.35;
136pub const THEME_LOGO_ACCENT_BANNER_SECONDARY_LIGHTEN_RATIO: f32 = 0.25;
137
138// UI Color constants
139pub const THEME_COLOR_WHITE_RED: u8 = 0xFF;
140pub const THEME_COLOR_WHITE_GREEN: u8 = 0xFF;
141pub const THEME_COLOR_WHITE_BLUE: u8 = 0xFF;
142pub const THEME_MIX_RATIO_MIN: f32 = 0.0;
143pub const THEME_MIX_RATIO_MAX: f32 = 1.0;
144pub const THEME_BLEND_CLAMP_MIN: f32 = 0.0;
145pub const THEME_BLEND_CLAMP_MAX: f32 = 255.0;
146
147// WCAG contrast algorithm constants
148pub const THEME_RELATIVE_LUMINANCE_CUTOFF: f32 = 0.03928;
149pub const THEME_RELATIVE_LUMINANCE_LOW_FACTOR: f32 = 12.92;
150pub const THEME_RELATIVE_LUMINANCE_OFFSET: f32 = 0.055;
151pub const THEME_RELATIVE_LUMINANCE_EXPONENT: f32 = 2.4;
152pub const THEME_CONTRAST_RATIO_OFFSET: f32 = 0.05;
153pub const THEME_RED_LUMINANCE_COEFFICIENT: f32 = 0.2126;
154pub const THEME_GREEN_LUMINANCE_COEFFICIENT: f32 = 0.7152;
155pub const THEME_BLUE_LUMINANCE_COEFFICIENT: f32 = 0.0722;
156pub const THEME_LUMINANCE_LIGHTEN_RATIO: f32 = 0.2;
157/// Mix ratio for PTY output text toward background — dim but readable (higher = dimmer)
158pub const THEME_PTY_OUTPUT_MIX_RATIO: f32 = 0.50;
159
160/// Medium gray for input placeholder text
161pub const PLACEHOLDER_R: u8 = 0x88;
162pub const PLACEHOLDER_G: u8 = 0x88;
163pub const PLACEHOLDER_B: u8 = 0x88;
164
165// Safe ANSI palette. Terminal color portability research:
166// <https://blog.xoria.org/terminal-colors/>
167// Only the bright-cyan index is shared with the config layer; the rest of the
168// palette lives in `vtcode_ui::tui::config::constants::ui`.
169
170/// Bright cyan (ANSI 14) — used for modal list title/border accents.
171pub const SAFE_ANSI_BRIGHT_CYAN: u8 = 14;
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    /// Representative terminal backgrounds the agent badge is rendered against.
178    /// A near-black dark theme (Catppuccin Mocha / Gruvbox) and a near-white
179    /// light theme (Solarized Light / Catppuccin Latte).
180    const DARK_BG: (u8, u8, u8) = (0x18, 0x18, 0x18);
181    const LIGHT_BG: (u8, u8, u8) = (0xFD, 0xF6, 0xE3);
182
183    /// Parse a `#rrggbb` token to its RGB triple. Returns `None` for anything
184    /// else (hue names fall through to the variant table below).
185    fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
186        let hex = hex.strip_prefix('#')?;
187        if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
188            return None;
189        }
190        let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
191        Some((channel(0)?, channel(2)?, channel(4)?))
192    }
193
194    /// Standard ANSI variant RGB values used by the design system for each hue
195    /// (base + bright), plus the fixed Build hex. Hue badges pick the bright
196    /// variant on dark and the base variant on light, so both appearances stay
197    /// readable; the hex renders identically on both.
198    fn hue_rgb_on(hue: &str, light: bool) -> Option<(u8, u8, u8)> {
199        if let Some(rgb) = hex_to_rgb(hue) {
200            return Some(rgb);
201        }
202        let (d, l) = match hue {
203            "red" => ((0xFF, 0x5F, 0x5F), (0xCD, 0x00, 0x00)),
204            "green" => ((0x5F, 0xFF, 0x5F), (0x00, 0xCD, 0x00)),
205            "blue" => ((0x5F, 0x5F, 0xFF), (0x00, 0x00, 0xCD)),
206            "magenta" => ((0xFF, 0x5F, 0xFF), (0xCD, 0x00, 0xCD)),
207            "yellow" => ((0xFF, 0xFF, 0x5F), (0xCD, 0xCD, 0x00)),
208            "cyan" => ((0x5F, 0xFF, 0xFF), (0x00, 0xCD, 0xCD)),
209            _ => return None,
210        };
211        Some(if light { l } else { d })
212    }
213
214    fn channel_lin(c: u8) -> f32 {
215        let c = c as f32 / 255.0;
216        if c <= 0.03928 {
217            c / 12.92
218        } else {
219            ((c + 0.055) / 1.055).powf(2.4)
220        }
221    }
222
223    fn relative_luminance((r, g, b): (u8, u8, u8)) -> f32 {
224        0.2126 * channel_lin(r) + 0.7152 * channel_lin(g) + 0.0722 * channel_lin(b)
225    }
226
227    fn contrast_ratio(fg: (u8, u8, u8), bg: (u8, u8, u8)) -> f32 {
228        let l_fg = relative_luminance(fg);
229        let l_bg = relative_luminance(bg);
230        let (hi, lo) = if l_fg > l_bg { (l_fg, l_bg) } else { (l_bg, l_fg) };
231        (hi + 0.05) / (lo + 0.05)
232    }
233
234    /// Each mode color must resolve to a variant that stays legible on its
235    /// matching background. Dark terminals clear the WCAG "large/bold text"
236    /// 3:1 bar comfortably. On light terminals the ANSI hues clear 3:1 except
237    /// green (~2:1 — the best a standard `Green` code can do against white),
238    /// and the Build hex clears 2.7:1, so the light threshold stays at 2:1.
239    const MIN_CONTRAST_DARK: f32 = 3.0;
240    const MIN_CONTRAST_LIGHT: f32 = 2.0;
241
242    fn assert_agent_hue_readable(name: &str, hue: &str, light: bool, bg: (u8, u8, u8)) {
243        let fg =
244            hue_rgb_on(hue, light).unwrap_or_else(|| panic!("{name} ({hue}) is not a supported standard ANSI hue"));
245        let cr = contrast_ratio(fg, bg);
246        let min = if light { MIN_CONTRAST_LIGHT } else { MIN_CONTRAST_DARK };
247        assert!(
248            cr >= min,
249            "{name} ({hue}) on {} bg contrast {cr:.2} below {min}",
250            if light { "light" } else { "dark" }
251        );
252    }
253
254    #[test]
255    fn agent_mode_color_tokens_are_supported() {
256        for (_, hue) in AGENT_MODE_HUE {
257            let supported = AGENT_HUE_NAMES.contains(hue) || hex_to_rgb(hue).is_some();
258            assert!(supported, "mode token {hue} is neither a standard ANSI hue nor #rrggbb");
259        }
260    }
261
262    #[test]
263    fn agent_mode_colors_are_readable_in_dark_and_light_terminals() {
264        for (mode, hue) in AGENT_MODE_HUE {
265            // Dark terminals use the bright variant; light terminals the base.
266            assert_agent_hue_readable(mode, hue, false, DARK_BG);
267            assert_agent_hue_readable(mode, hue, true, LIGHT_BG);
268        }
269    }
270
271    /// The four mode colors must be visually distinct from one another so the
272    /// user can tell Build / Auto / Plan / Duck apart at a glance.
273    #[test]
274    fn agent_mode_colors_are_distinct() {
275        let hues: Vec<&str> = AGENT_MODE_HUE.iter().map(|(_, h)| *h).collect();
276        for i in 0..hues.len() {
277            for j in (i + 1)..hues.len() {
278                assert_ne!(hues[i], hues[j], "mode hues {} and {} are identical", hues[i], hues[j]);
279            }
280        }
281    }
282
283    #[test]
284    fn agent_mode_hue_resolves_mode_names() {
285        assert_eq!(agent_mode_hue("build"), Some(AGENT_COLOR_BUILD));
286        assert_eq!(agent_mode_hue("auto"), Some(AGENT_COLOR_AUTO));
287        assert_eq!(agent_mode_hue("plan"), Some(AGENT_COLOR_PLAN));
288        assert_eq!(agent_mode_hue("duck"), Some(AGENT_COLOR_DUCK));
289        assert_eq!(agent_mode_hue("unknown"), None);
290    }
291
292    #[test]
293    fn agent_mode_colors_are_wired_into_builtin_primary_agents() {
294        // Guards against the constants drifting from the specs that expose them.
295        assert_eq!(AGENT_COLOR_BUILD, "#73a18e");
296        assert_eq!(AGENT_COLOR_AUTO, "green");
297        assert_eq!(AGENT_COLOR_PLAN, "blue");
298        assert_eq!(AGENT_COLOR_DUCK, "magenta");
299    }
300
301    /// Chat placeholders share one hint fragment so they cannot drift apart.
302    #[test]
303    fn chat_input_placeholders_share_one_shortcut_hint() {
304        let hint = chat_input_shortcut_hint!();
305        assert!(CHAT_INPUT_PLACEHOLDER_BOOTSTRAP.ends_with(hint));
306        assert!(CHAT_INPUT_PLACEHOLDER_FOLLOW_UP.ends_with(hint));
307        assert!(CHAT_INPUT_PLACEHOLDER_BOOTSTRAP.starts_with("Type a request"));
308        assert!(CHAT_INPUT_PLACEHOLDER_FOLLOW_UP.starts_with("Continue"));
309        assert_ne!(CHAT_INPUT_PLACEHOLDER_BOOTSTRAP, CHAT_INPUT_PLACEHOLDER_FOLLOW_UP);
310    }
311
312    /// The metadata bullet separator has exactly one definition.
313    ///
314    /// Every `" • "` join reaches this constant instead of re-typing the
315    /// literal, so spacing changes land in one place.
316    #[test]
317    fn meta_bullet_separator_is_the_single_source() {
318        assert_eq!(META_BULLET_SEPARATOR, " • ");
319    }
320
321    /// Ratchet against the duplication that made this module rot.
322    ///
323    /// These constants were removed because `vtcode-ui` defines its own copy
324    /// under `tui::config::constants::ui` and never reads the one here, so the
325    /// two silently diverged. Re-adding any of them recreates that trap; the
326    /// TUI value is the live one, so edit it there instead.
327    #[test]
328    fn removed_shadowed_tui_constants_stay_removed() {
329        const SHADOWED: &[&str] = &[
330            "TUI_ACTIVE_TICK_RATE_HZ",
331            "TUI_IDLE_TICK_RATE_HZ",
332            "TUI_ACTIVE_TIMEOUT_MS",
333            "TUI_SHIMMER_FRAME_INTERVAL_MS",
334            "TUI_SHIMMER_SWEEP_DURATION_MS",
335            "TUI_SCROLL_CURSOR_STEADY_MS",
336            "TUI_MAX_VIEWPORT_WIDTH",
337            "TUI_MAX_VIEWPORT_HEIGHT",
338            "MODAL_WIDTH_RATIO",
339            "MODAL_HEIGHT_RATIO",
340            "MODAL_MAX_WIDTH_RATIO",
341            "MODAL_MAX_HEIGHT_RATIO",
342            "SLASH_PALETTE_MIN_WIDTH",
343            "SLASH_PALETTE_MIN_HEIGHT",
344            "HEADER_SHORTCUT_HINT",
345            "SCROLL_INDICATOR_FORMAT",
346            "HEADER_META_SEPARATOR",
347            "SLASH_PALETTE_TOP_OFFSET",
348        ];
349        // Source-level assertion: these names must not be declared here again.
350        let src = include_str!("ui.rs");
351        for name in SHADOWED {
352            let declared = src.lines().any(|line| {
353                let trimmed = line.trim_start();
354                trimmed
355                    .strip_prefix("pub const ")
356                    .or_else(|| trimmed.strip_prefix("pub(crate) const "))
357                    .is_some_and(|rest| rest.starts_with(&format!("{name}:")))
358            });
359            assert!(!declared, "`{name}` is shadowed by vtcode-ui; declare the live value there, not here");
360        }
361    }
362}