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;
120pub const THEME_FOREGROUND_LIGHTEN_RATIO: f32 = 0.25;
121pub const THEME_SECONDARY_LIGHTEN_RATIO: f32 = 0.2;
122pub const THEME_MIX_RATIO: f32 = 0.35;
123pub const THEME_TOOL_BODY_MIX_RATIO: f32 = 0.35;
124pub const THEME_TOOL_BODY_LIGHTEN_RATIO: f32 = 0.2;
125pub const THEME_RESPONSE_COLOR_LIGHTEN_RATIO: f32 = 0.15;
126pub const THEME_INPUT_BACKGROUND_MIX_RATIO: f32 = 0.08;
127pub const THEME_USER_COLOR_LIGHTEN_RATIO: f32 = 0.2;
128pub const THEME_SECONDARY_USER_COLOR_LIGHTEN_RATIO: f32 = 0.4;
129pub const THEME_PRIMARY_STATUS_LIGHTEN_RATIO: f32 = 0.35;
130pub const THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO: f32 = 0.5;
131pub const THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO: f32 = 0.35;
132pub const THEME_LOGO_ACCENT_BANNER_SECONDARY_LIGHTEN_RATIO: f32 = 0.25;
133
134// UI Color constants
135pub const THEME_COLOR_WHITE_RED: u8 = 0xFF;
136pub const THEME_COLOR_WHITE_GREEN: u8 = 0xFF;
137pub const THEME_COLOR_WHITE_BLUE: u8 = 0xFF;
138pub const THEME_MIX_RATIO_MIN: f32 = 0.0;
139pub const THEME_MIX_RATIO_MAX: f32 = 1.0;
140pub const THEME_BLEND_CLAMP_MIN: f32 = 0.0;
141pub const THEME_BLEND_CLAMP_MAX: f32 = 255.0;
142
143// WCAG contrast algorithm constants
144pub const THEME_RELATIVE_LUMINANCE_CUTOFF: f32 = 0.03928;
145pub const THEME_RELATIVE_LUMINANCE_LOW_FACTOR: f32 = 12.92;
146pub const THEME_RELATIVE_LUMINANCE_OFFSET: f32 = 0.055;
147pub const THEME_RELATIVE_LUMINANCE_EXPONENT: f32 = 2.4;
148pub const THEME_CONTRAST_RATIO_OFFSET: f32 = 0.05;
149pub const THEME_RED_LUMINANCE_COEFFICIENT: f32 = 0.2126;
150pub const THEME_GREEN_LUMINANCE_COEFFICIENT: f32 = 0.7152;
151pub const THEME_BLUE_LUMINANCE_COEFFICIENT: f32 = 0.0722;
152pub const THEME_LUMINANCE_LIGHTEN_RATIO: f32 = 0.2;
153/// Mix ratio for PTY output text toward background — dim but readable (higher = dimmer)
154pub const THEME_PTY_OUTPUT_MIX_RATIO: f32 = 0.50;
155
156/// Medium gray for input placeholder text
157pub const PLACEHOLDER_R: u8 = 0x88;
158pub const PLACEHOLDER_G: u8 = 0x88;
159pub const PLACEHOLDER_B: u8 = 0x88;
160
161// Safe ANSI palette. Terminal color portability research:
162// <https://blog.xoria.org/terminal-colors/>
163// Only the bright-cyan index is shared with the config layer; the rest of the
164// palette lives in `vtcode_ui::tui::config::constants::ui`.
165
166/// Bright cyan (ANSI 14) — used for modal list title/border accents.
167pub const SAFE_ANSI_BRIGHT_CYAN: u8 = 14;
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    /// Representative terminal backgrounds the agent badge is rendered against.
174    /// A near-black dark theme (Catppuccin Mocha / Gruvbox) and a near-white
175    /// light theme (Solarized Light / Catppuccin Latte).
176    const DARK_BG: (u8, u8, u8) = (0x18, 0x18, 0x18);
177    const LIGHT_BG: (u8, u8, u8) = (0xFD, 0xF6, 0xE3);
178
179    /// Parse a `#rrggbb` token to its RGB triple. Returns `None` for anything
180    /// else (hue names fall through to the variant table below).
181    fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
182        let hex = hex.strip_prefix('#')?;
183        if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
184            return None;
185        }
186        let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
187        Some((channel(0)?, channel(2)?, channel(4)?))
188    }
189
190    /// Standard ANSI variant RGB values used by the design system for each hue
191    /// (base + bright), plus the fixed Build hex. Hue badges pick the bright
192    /// variant on dark and the base variant on light, so both appearances stay
193    /// readable; the hex renders identically on both.
194    fn hue_rgb_on(hue: &str, light: bool) -> Option<(u8, u8, u8)> {
195        if let Some(rgb) = hex_to_rgb(hue) {
196            return Some(rgb);
197        }
198        let (d, l) = match hue {
199            "red" => ((0xFF, 0x5F, 0x5F), (0xCD, 0x00, 0x00)),
200            "green" => ((0x5F, 0xFF, 0x5F), (0x00, 0xCD, 0x00)),
201            "blue" => ((0x5F, 0x5F, 0xFF), (0x00, 0x00, 0xCD)),
202            "magenta" => ((0xFF, 0x5F, 0xFF), (0xCD, 0x00, 0xCD)),
203            "yellow" => ((0xFF, 0xFF, 0x5F), (0xCD, 0xCD, 0x00)),
204            "cyan" => ((0x5F, 0xFF, 0xFF), (0x00, 0xCD, 0xCD)),
205            _ => return None,
206        };
207        Some(if light { l } else { d })
208    }
209
210    fn channel_lin(c: u8) -> f32 {
211        let c = c as f32 / 255.0;
212        if c <= 0.03928 {
213            c / 12.92
214        } else {
215            ((c + 0.055) / 1.055).powf(2.4)
216        }
217    }
218
219    fn relative_luminance((r, g, b): (u8, u8, u8)) -> f32 {
220        0.2126 * channel_lin(r) + 0.7152 * channel_lin(g) + 0.0722 * channel_lin(b)
221    }
222
223    fn contrast_ratio(fg: (u8, u8, u8), bg: (u8, u8, u8)) -> f32 {
224        let l_fg = relative_luminance(fg);
225        let l_bg = relative_luminance(bg);
226        let (hi, lo) = if l_fg > l_bg { (l_fg, l_bg) } else { (l_bg, l_fg) };
227        (hi + 0.05) / (lo + 0.05)
228    }
229
230    /// Each mode color must resolve to a variant that stays legible on its
231    /// matching background. Dark terminals clear the WCAG "large/bold text"
232    /// 3:1 bar comfortably. On light terminals the ANSI hues clear 3:1 except
233    /// green (~2:1 — the best a standard `Green` code can do against white),
234    /// and the Build hex clears 2.7:1, so the light threshold stays at 2:1.
235    const MIN_CONTRAST_DARK: f32 = 3.0;
236    const MIN_CONTRAST_LIGHT: f32 = 2.0;
237
238    fn assert_agent_hue_readable(name: &str, hue: &str, light: bool, bg: (u8, u8, u8)) {
239        let fg =
240            hue_rgb_on(hue, light).unwrap_or_else(|| panic!("{name} ({hue}) is not a supported standard ANSI hue"));
241        let cr = contrast_ratio(fg, bg);
242        let min = if light { MIN_CONTRAST_LIGHT } else { MIN_CONTRAST_DARK };
243        assert!(
244            cr >= min,
245            "{name} ({hue}) on {} bg contrast {cr:.2} below {min}",
246            if light { "light" } else { "dark" }
247        );
248    }
249
250    #[test]
251    fn agent_mode_color_tokens_are_supported() {
252        for (_, hue) in AGENT_MODE_HUE {
253            let supported = AGENT_HUE_NAMES.contains(hue) || hex_to_rgb(hue).is_some();
254            assert!(supported, "mode token {hue} is neither a standard ANSI hue nor #rrggbb");
255        }
256    }
257
258    #[test]
259    fn agent_mode_colors_are_readable_in_dark_and_light_terminals() {
260        for (mode, hue) in AGENT_MODE_HUE {
261            // Dark terminals use the bright variant; light terminals the base.
262            assert_agent_hue_readable(mode, hue, false, DARK_BG);
263            assert_agent_hue_readable(mode, hue, true, LIGHT_BG);
264        }
265    }
266
267    /// The four mode colors must be visually distinct from one another so the
268    /// user can tell Build / Auto / Plan / Duck apart at a glance.
269    #[test]
270    fn agent_mode_colors_are_distinct() {
271        let hues: Vec<&str> = AGENT_MODE_HUE.iter().map(|(_, h)| *h).collect();
272        for i in 0..hues.len() {
273            for j in (i + 1)..hues.len() {
274                assert_ne!(hues[i], hues[j], "mode hues {} and {} are identical", hues[i], hues[j]);
275            }
276        }
277    }
278
279    #[test]
280    fn agent_mode_hue_resolves_mode_names() {
281        assert_eq!(agent_mode_hue("build"), Some(AGENT_COLOR_BUILD));
282        assert_eq!(agent_mode_hue("auto"), Some(AGENT_COLOR_AUTO));
283        assert_eq!(agent_mode_hue("plan"), Some(AGENT_COLOR_PLAN));
284        assert_eq!(agent_mode_hue("duck"), Some(AGENT_COLOR_DUCK));
285        assert_eq!(agent_mode_hue("unknown"), None);
286    }
287
288    #[test]
289    fn agent_mode_colors_are_wired_into_builtin_primary_agents() {
290        // Guards against the constants drifting from the specs that expose them.
291        assert_eq!(AGENT_COLOR_BUILD, "#73a18e");
292        assert_eq!(AGENT_COLOR_AUTO, "green");
293        assert_eq!(AGENT_COLOR_PLAN, "blue");
294        assert_eq!(AGENT_COLOR_DUCK, "magenta");
295    }
296
297    /// Chat placeholders share one hint fragment so they cannot drift apart.
298    #[test]
299    fn chat_input_placeholders_share_one_shortcut_hint() {
300        let hint = chat_input_shortcut_hint!();
301        assert!(CHAT_INPUT_PLACEHOLDER_BOOTSTRAP.ends_with(hint));
302        assert!(CHAT_INPUT_PLACEHOLDER_FOLLOW_UP.ends_with(hint));
303        assert!(CHAT_INPUT_PLACEHOLDER_BOOTSTRAP.starts_with("Type a request"));
304        assert!(CHAT_INPUT_PLACEHOLDER_FOLLOW_UP.starts_with("Continue"));
305        assert_ne!(CHAT_INPUT_PLACEHOLDER_BOOTSTRAP, CHAT_INPUT_PLACEHOLDER_FOLLOW_UP);
306    }
307
308    /// The metadata bullet separator has exactly one definition.
309    ///
310    /// Every `" • "` join reaches this constant instead of re-typing the
311    /// literal, so spacing changes land in one place.
312    #[test]
313    fn meta_bullet_separator_is_the_single_source() {
314        assert_eq!(META_BULLET_SEPARATOR, " • ");
315    }
316
317    /// Ratchet against the duplication that made this module rot.
318    ///
319    /// These constants were removed because `vtcode-ui` defines its own copy
320    /// under `tui::config::constants::ui` and never reads the one here, so the
321    /// two silently diverged. Re-adding any of them recreates that trap; the
322    /// TUI value is the live one, so edit it there instead.
323    #[test]
324    fn removed_shadowed_tui_constants_stay_removed() {
325        const SHADOWED: &[&str] = &[
326            "TUI_ACTIVE_TICK_RATE_HZ",
327            "TUI_IDLE_TICK_RATE_HZ",
328            "TUI_ACTIVE_TIMEOUT_MS",
329            "TUI_SHIMMER_FRAME_INTERVAL_MS",
330            "TUI_SHIMMER_SWEEP_DURATION_MS",
331            "TUI_SCROLL_CURSOR_STEADY_MS",
332            "TUI_MAX_VIEWPORT_WIDTH",
333            "TUI_MAX_VIEWPORT_HEIGHT",
334            "MODAL_WIDTH_RATIO",
335            "MODAL_HEIGHT_RATIO",
336            "MODAL_MAX_WIDTH_RATIO",
337            "MODAL_MAX_HEIGHT_RATIO",
338            "SLASH_PALETTE_MIN_WIDTH",
339            "SLASH_PALETTE_MIN_HEIGHT",
340            "HEADER_SHORTCUT_HINT",
341            "SCROLL_INDICATOR_FORMAT",
342            "HEADER_META_SEPARATOR",
343            "SLASH_PALETTE_TOP_OFFSET",
344        ];
345        // Source-level assertion: these names must not be declared here again.
346        let src = include_str!("ui.rs");
347        for name in SHADOWED {
348            let declared = src.lines().any(|line| {
349                let trimmed = line.trim_start();
350                trimmed
351                    .strip_prefix("pub const ")
352                    .or_else(|| trimmed.strip_prefix("pub(crate) const "))
353                    .is_some_and(|rest| rest.starts_with(&format!("{name}:")))
354            });
355            assert!(!declared, "`{name}` is shadowed by vtcode-ui; declare the live value there, not here");
356        }
357    }
358}