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 SLASH_SUGGESTION_LIMIT: usize = 50; // All commands are scrollable
6pub const SLASH_PALETTE_MIN_WIDTH: u16 = 40;
7pub const SLASH_PALETTE_MIN_HEIGHT: u16 = 9;
8pub const SLASH_PALETTE_HORIZONTAL_MARGIN: u16 = 8;
9pub const SLASH_PALETTE_TOP_OFFSET: u16 = 3;
10pub const SLASH_PALETTE_CONTENT_PADDING: u16 = 6;
11pub const SLASH_PALETTE_HINT_PRIMARY: &str = "Type to filter slash commands.";
12pub const SLASH_PALETTE_HINT_SECONDARY: &str = "Press Enter to apply • Esc to dismiss.";
13pub const MODAL_MIN_WIDTH: u16 = 36;
14pub const MODAL_MIN_HEIGHT: u16 = 16;
15pub const MODAL_LIST_MIN_HEIGHT: u16 = 12;
16pub const MODAL_WIDTH_RATIO: f32 = 0.6;
17pub const MODAL_HEIGHT_RATIO: f32 = 0.6;
18pub const MODAL_MAX_WIDTH_RATIO: f32 = 0.9;
19pub const MODAL_MAX_HEIGHT_RATIO: f32 = 0.8;
20pub const MODAL_CONTENT_HORIZONTAL_PADDING: u16 = 8;
21pub const MODAL_CONTENT_VERTICAL_PADDING: u16 = 6;
22pub const MODAL_INSTRUCTIONS_TITLE: &str = "";
23pub const MODAL_INSTRUCTIONS_BULLET: &str = "•";
24pub const INLINE_HEADER_HEIGHT: u16 = 3;
25pub const INLINE_INPUT_HEIGHT: u16 = 4;
26pub const INLINE_INPUT_PADDING_HORIZONTAL: u16 = 1;
27pub const INLINE_INPUT_PADDING_VERTICAL: u16 = 1;
28pub const INLINE_INPUT_MAX_LINES: usize = 10;
29pub const INLINE_NAVIGATION_PERCENT: u16 = 28;
30pub const INLINE_NAVIGATION_MIN_WIDTH: u16 = 24;
31pub const INLINE_CONTENT_MIN_WIDTH: u16 = 48;
32pub const INLINE_STACKED_NAVIGATION_PERCENT: u16 = INLINE_NAVIGATION_PERCENT;
33pub const INLINE_SCROLLBAR_EDGE_PADDING: u16 = 1;
34/// Transcript bottom padding rows.
35///
36/// Compatibility duplicate: no in-repo readers. The live TUI value is
37/// `vtcode_ui::tui::config::constants::ui::INLINE_TRANSCRIPT_BOTTOM_PADDING`
38/// (with `effective_transcript_bottom_padding`); keep the two in sync and do
39/// not add new readers of this constant.
40pub const INLINE_TRANSCRIPT_BOTTOM_PADDING: u16 = 2;
41pub const INLINE_PREVIEW_MAX_CHARS: usize = 56;
42pub const INLINE_PREVIEW_ELLIPSIS: &str = "…";
43pub const INLINE_PASTE_COLLAPSE_LINE_THRESHOLD: usize = 10;
44pub const HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS: usize = 48;
45pub const INLINE_AGENT_MESSAGE_LEFT_PADDING: &str = " ";
46pub const INLINE_AGENT_QUOTE_PREFIX: &str = " •";
47pub const INLINE_USER_MESSAGE_DIVIDER_SYMBOL: &str = "─";
48
49/// Scroll percentage format in status bar
50pub const SCROLL_INDICATOR_FORMAT: &str = "↕";
51/// Show scroll percentage in status bar
52pub const SCROLL_INDICATOR_ENABLED: bool = false;
53
54pub const INLINE_BLOCK_TOP_LEFT: &str = "╭";
55pub const INLINE_BLOCK_TOP_RIGHT: &str = "╮";
56pub const INLINE_BLOCK_BODY_LEFT: &str = "│";
57pub const INLINE_BLOCK_BODY_RIGHT: &str = "│";
58pub const INLINE_BLOCK_BOTTOM_LEFT: &str = "╰";
59pub const INLINE_BLOCK_BOTTOM_RIGHT: &str = "╯";
60pub const INLINE_BLOCK_HORIZONTAL: &str = "─";
61pub const INLINE_TOOL_HEADER_LABEL: &str = "Tool";
62pub const INLINE_TOOL_ACTION_PREFIX: &str = "→";
63pub const INLINE_TOOL_DETAIL_PREFIX: &str = "↳";
64pub const INLINE_PTY_HEADER_LABEL: &str = "Terminal";
65pub const INLINE_PTY_RUNNING_LABEL: &str = "running";
66pub const INLINE_PTY_STATUS_LIVE: &str = "LIVE";
67pub const INLINE_PTY_STATUS_DONE: &str = "DONE";
68pub const INLINE_PTY_PLACEHOLDER: &str = "Terminal output";
69pub const MODAL_LIST_HIGHLIGHT_SYMBOL: &str = "│";
70pub const MODAL_LIST_HIGHLIGHT_FULL: &str = "│ ";
71pub const MODAL_LIST_SUMMARY_FILTER_LABEL: &str = "Filter";
72pub const MODAL_LIST_SUMMARY_SEPARATOR: &str = " • ";
73pub const MODAL_LIST_SUMMARY_MATCHES_LABEL: &str = "Matches";
74pub const MODAL_LIST_SUMMARY_TOTAL_LABEL: &str = "of";
75pub const MODAL_LIST_SUMMARY_NO_MATCHES: &str = "No matches";
76pub const MODAL_LIST_SUMMARY_RESET_HINT: &str = "Press Esc to reset";
77pub const MODAL_LIST_NO_RESULTS_MESSAGE: &str = "No matching options";
78pub const HEADER_VERSION_PROMPT: &str = "> ";
79pub const HEADER_VERSION_PREFIX: &str = "VT Code";
80pub const HEADER_VERSION_LEFT_DELIMITER: &str = "(";
81pub const HEADER_VERSION_RIGHT_DELIMITER: &str = ")";
82pub const HEADER_PROVIDER_PREFIX: &str = "Provider: ";
83pub const HEADER_MODEL_PREFIX: &str = "Model: ";
84pub const HEADER_SERVICE_TIER_PREFIX: &str = "Tier: ";
85pub const HEADER_REASONING_PREFIX: &str = "";
86pub const HEADER_TRUST_PREFIX: &str = "Trust: ";
87pub const HEADER_TOOLS_PREFIX: &str = "Tools: ";
88pub const HEADER_MCP_PREFIX: &str = "MCP: ";
89pub const HEADER_GIT_PREFIX: &str = "git: ";
90pub const HEADER_GIT_CLEAN_SUFFIX: &str = "✓";
91pub const HEADER_GIT_DIRTY_SUFFIX: &str = "*";
92pub const HEADER_UNKNOWN_PLACEHOLDER: &str = "unavailable";
93pub const HEADER_STATUS_LABEL: &str = "Status";
94pub const HEADER_STATUS_ACTIVE: &str = "Active";
95pub const HEADER_STATUS_PAUSED: &str = "Paused";
96pub const HEADER_MESSAGES_LABEL: &str = "Messages";
97pub const HEADER_INPUT_LABEL: &str = "Input";
98pub const HEADER_INPUT_ENABLED: &str = "Enabled";
99pub const HEADER_INPUT_DISABLED: &str = "Disabled";
100pub const INLINE_USER_PREFIX: &str = " ";
101pub const CHAT_INPUT_PLACEHOLDER_BOOTSTRAP: &str =
102    "Type a request… Tab switch agent • @files /commands • Enter run/steer • Ctrl+Enter queue";
103pub const CHAT_INPUT_PLACEHOLDER_FOLLOW_UP: &str =
104    "Continue… Tab switch agent • @files /commands • Enter run/steer • Ctrl+Enter queue";
105pub const CHAT_INPUT_PLACEHOLDER_INTERRUPTED: &str =
106    "Interrupted · What should VT Code do instead? • Esc cancel • Ctrl+C interrupt • Ctrl+D exit";
107pub const HEADER_SHORTCUT_HINT: &str =
108    "? help • / command • @ file • Tab agent • Enter run/steer • Ctrl+Enter queue • Esc cancel • Ctrl+C interrupt";
109pub const HEADER_META_SEPARATOR: &str = "   ";
110pub const WELCOME_TEXT_WIDTH: usize = 80;
111pub const WELCOME_SHORTCUT_SECTION_TITLE: &str = "Keyboard Shortcuts";
112pub const WELCOME_SHORTCUT_HINT_PREFIX: &str = "Shortcuts:";
113pub const WELCOME_SHORTCUT_SEPARATOR: &str = "•";
114pub const WELCOME_SHORTCUT_INDENT: &str = "  ";
115pub const WELCOME_SLASH_COMMAND_SECTION_TITLE: &str = "Slash Commands";
116pub const WELCOME_SLASH_COMMAND_LIMIT: usize = 6;
117pub const WELCOME_SLASH_COMMAND_PREFIX: &str = "/";
118pub const WELCOME_SLASH_COMMAND_INTRO: &str = "";
119pub const WELCOME_SLASH_COMMAND_INDENT: &str = "  ";
120pub const NAVIGATION_BLOCK_TITLE: &str = "Timeline";
121pub const NAVIGATION_BLOCK_SHORTCUT_NOTE: &str = "Ctrl+T";
122pub const NAVIGATION_EMPTY_LABEL: &str = "Waiting for activity";
123pub const NAVIGATION_INDEX_PREFIX: &str = "#";
124pub const NAVIGATION_LABEL_AGENT: &str = "Agent";
125pub const NAVIGATION_LABEL_ERROR: &str = "Error";
126pub const NAVIGATION_LABEL_INFO: &str = "Info";
127pub const NAVIGATION_LABEL_POLICY: &str = "Policy";
128pub const NAVIGATION_LABEL_TOOL: &str = "Tool";
129pub const NAVIGATION_LABEL_USER: &str = "User";
130pub const NAVIGATION_LABEL_PTY: &str = "PTY";
131pub const PLAN_BLOCK_TITLE: &str = "TODOs";
132pub const PLAN_STATUS_EMPTY: &str = "No TODOs";
133pub const PLAN_STATUS_IN_PROGRESS: &str = "In progress";
134pub const PLAN_STATUS_DONE: &str = "Done";
135pub const PLAN_IN_PROGRESS_NOTE: &str = "in progress";
136pub const SUGGESTION_BLOCK_TITLE: &str = "Slash Commands";
137pub const STATUS_LINE_MODE: &str = "auto";
138pub(crate) const STATUS_LINE_REFRESH_INTERVAL_MS: u64 = 1000;
139pub(crate) const STATUS_LINE_COMMAND_TIMEOUT_MS: u64 = 200;
140
141// Agent/Mode color constants.
142//
143// Mode badges resolve through one canonical token per mode. Most modes use a
144// **standard ANSI hue name** (green/blue/magenta/...), which the design system
145// (`vtcode-ui`) maps to a theme-appropriate variant at render time: the
146// brighter `Light*` ANSI variant on dark terminals, the base variant on light
147// terminals. This keeps a single, portable token readable on BOTH dark and
148// light terminals without per-theme hex tuning, and reuses the same standard
149// palette the rest of the UI already relies on (see `SAFE_ANSI_*`). Build is
150// the exception: it uses a fixed hex token that renders identically on both
151// appearances.
152//
153/// Standard ANSI hue names usable for agent/mode badges. Must stay in sync with
154/// the variant table in `vtcode-ui`'s design color resolver.
155const AGENT_HUE_NAMES: &[&str] = &["red", "green", "blue", "magenta", "yellow", "cyan"];
156
157/// Build agent color - sage `#73a18e` (calm, implementation tone).
158pub const AGENT_COLOR_BUILD: &str = "#73a18e";
159/// Auto agent hue - green (autonomy and forward progress).
160pub const AGENT_COLOR_AUTO: &str = "green";
161/// Plan agent hue - blue (thoughtful, planning tone).
162pub const AGENT_COLOR_PLAN: &str = "blue";
163/// Duck agent hue - magenta (soft, discussion-focused).
164pub const AGENT_COLOR_DUCK: &str = "magenta";
165
166/// Canonical primary-agent mode -> color token mapping (single source of
167/// truth). `subagents.rs` assigns the color to each built-in spec from here.
168const AGENT_MODE_HUE: &[(&str, &str)] = &[
169    ("build", AGENT_COLOR_BUILD),
170    ("auto", AGENT_COLOR_AUTO),
171    ("plan", AGENT_COLOR_PLAN),
172    ("duck", AGENT_COLOR_DUCK),
173];
174
175/// Resolve a primary-agent mode name to its canonical color token
176/// (e.g. `"build"` -> `"#73a18e"`, `"plan"` -> `"blue"`).
177pub fn agent_mode_hue(mode: &str) -> Option<&'static str> {
178    AGENT_MODE_HUE.iter().find(|(m, _)| *m == mode).map(|(_, h)| *h)
179}
180
181// TUI tick rates are event-driven with on-demand animation ticks.
182// Input, commands (new messages), and crossterm events render immediately
183// without waiting for a tick; ticks exist only for animations (spinner 80ms,
184// shimmer 33ms, drag 50ms) and transient expiry cleanup (scroll-steady 250ms,
185// copy notification 2s). Keep in sync with
186// `vtcode-ui::tui::config::constants::ui` (the live TUI values).
187/// Tick rate (Hz) while interacting or animating — 60Hz matches display vsync.
188pub const TUI_ACTIVE_TICK_RATE_HZ: f64 = 60.0;
189/// Upkeep rate (Hz) when idle with nothing pending — safety net only.
190pub const TUI_IDLE_TICK_RATE_HZ: f64 = 4.0;
191/// Duration (ms) to remain at the active rate after last input or animation
192/// need. Animations extend active mode independently via `needs_animation_tick`.
193pub const TUI_ACTIVE_TIMEOUT_MS: u64 = 500;
194/// Shimmer frame interval in milliseconds
195pub const TUI_SHIMMER_FRAME_INTERVAL_MS: u64 = 33;
196/// Shimmer sweep duration in milliseconds
197pub const TUI_SHIMMER_SWEEP_DURATION_MS: u64 = 2000;
198/// Keep cursor steady for this duration after scroll events
199pub const TUI_SCROLL_CURSOR_STEADY_MS: u64 = 250;
200
201// Viewport size limits to prevent pathological CPU usage with huge terminals
202// (e.g., 2000+ columns causes 100% CPU without these guards)
203// See: <https://github.com/anthropics/claude-code/issues/21567>
204/// Maximum effective viewport width (columns) for rendering
205pub const TUI_MAX_VIEWPORT_WIDTH: u16 = 500;
206/// Maximum effective viewport height (rows) for rendering
207pub const TUI_MAX_VIEWPORT_HEIGHT: u16 = 200;
208
209// Theme and color constants
210pub const THEME_MIN_CONTRAST_RATIO: f32 = 4.5;
211pub const THEME_FOREGROUND_LIGHTEN_RATIO: f32 = 0.25;
212pub const THEME_SECONDARY_LIGHTEN_RATIO: f32 = 0.2;
213pub const THEME_MIX_RATIO: f32 = 0.35;
214pub const THEME_TOOL_BODY_MIX_RATIO: f32 = 0.35;
215pub const THEME_TOOL_BODY_LIGHTEN_RATIO: f32 = 0.2;
216pub const THEME_RESPONSE_COLOR_LIGHTEN_RATIO: f32 = 0.15;
217pub const THEME_REASONING_COLOR_LIGHTEN_RATIO: f32 = 0.3;
218pub const THEME_INPUT_BACKGROUND_MIX_RATIO: f32 = 0.08;
219pub const THEME_USER_COLOR_LIGHTEN_RATIO: f32 = 0.2;
220pub const THEME_SECONDARY_USER_COLOR_LIGHTEN_RATIO: f32 = 0.4;
221pub const THEME_PRIMARY_STATUS_LIGHTEN_RATIO: f32 = 0.35;
222pub const THEME_PRIMARY_STATUS_SECONDARY_LIGHTEN_RATIO: f32 = 0.5;
223pub const THEME_LOGO_ACCENT_BANNER_LIGHTEN_RATIO: f32 = 0.35;
224pub const THEME_LOGO_ACCENT_BANNER_SECONDARY_LIGHTEN_RATIO: f32 = 0.25;
225
226// UI Color constants
227pub const THEME_COLOR_WHITE_RED: u8 = 0xFF;
228pub const THEME_COLOR_WHITE_GREEN: u8 = 0xFF;
229pub const THEME_COLOR_WHITE_BLUE: u8 = 0xFF;
230pub const THEME_MIX_RATIO_MIN: f32 = 0.0;
231pub const THEME_MIX_RATIO_MAX: f32 = 1.0;
232pub const THEME_BLEND_CLAMP_MIN: f32 = 0.0;
233pub const THEME_BLEND_CLAMP_MAX: f32 = 255.0;
234
235// WCAG contrast algorithm constants
236pub const THEME_RELATIVE_LUMINANCE_CUTOFF: f32 = 0.03928;
237pub const THEME_RELATIVE_LUMINANCE_LOW_FACTOR: f32 = 12.92;
238pub const THEME_RELATIVE_LUMINANCE_OFFSET: f32 = 0.055;
239pub const THEME_RELATIVE_LUMINANCE_EXPONENT: f32 = 2.4;
240pub const THEME_CONTRAST_RATIO_OFFSET: f32 = 0.05;
241pub const THEME_RED_LUMINANCE_COEFFICIENT: f32 = 0.2126;
242pub const THEME_GREEN_LUMINANCE_COEFFICIENT: f32 = 0.7152;
243pub const THEME_BLUE_LUMINANCE_COEFFICIENT: f32 = 0.0722;
244pub const THEME_LUMINANCE_LIGHTEN_RATIO: f32 = 0.2;
245/// Mix ratio for PTY output text toward background — dim but readable (higher = dimmer)
246pub const THEME_PTY_OUTPUT_MIX_RATIO: f32 = 0.50;
247
248/// Subdued gray for diff line numbers
249pub const DIFF_LINE_NUMBER_R: u8 = 0x4a;
250pub const DIFF_LINE_NUMBER_G: u8 = 0x4a;
251pub const DIFF_LINE_NUMBER_B: u8 = 0x4a;
252/// Medium gray for input placeholder text
253pub const PLACEHOLDER_R: u8 = 0x88;
254pub const PLACEHOLDER_G: u8 = 0x88;
255pub const PLACEHOLDER_B: u8 = 0x88;
256
257// === Safe ANSI Color Palette ===
258// Based on terminal color portability research: <https://blog.xoria.org/terminal-colors/>
259// These 11 colors are safe across Basic (light/dark), Tango, and Solarized themes.
260// Colors NOT in this list have visibility issues in common terminal configurations.
261
262/// WCAG AA standard minimum contrast ratio (4.5:1)
263pub const WCAG_AA_CONTRAST_RATIO: f32 = 4.5;
264
265/// WCAG AAA standard minimum contrast ratio (7.0:1)
266pub const WCAG_AAA_CONTRAST_RATIO: f32 = 7.0;
267
268/// Large text minimum contrast ratio (3.0:1)
269pub const WCAG_LARGE_TEXT_CONTRAST_RATIO: f32 = 3.0;
270
271// Safe ANSI color indices (standard 0-15 palette)
272// These colors are portable across common terminal themes.
273
274/// Safe regular colors (ANSI 0-7 subset that works everywhere)
275/// Note: black (0) and white (7) are excluded due to theme conflicts
276pub const SAFE_ANSI_RED: u8 = 1;
277pub const SAFE_ANSI_GREEN: u8 = 2;
278pub const SAFE_ANSI_YELLOW: u8 = 3;
279pub const SAFE_ANSI_BLUE: u8 = 4;
280pub const SAFE_ANSI_MAGENTA: u8 = 5;
281pub const SAFE_ANSI_CYAN: u8 = 6;
282
283/// Safe bright colors (ANSI 8-15 subset that works everywhere)
284/// Note: brblack (8) is invisible in Solarized Dark
285/// Note: bryellow (11), brblue (12), brwhite (15) have visibility issues
286pub const SAFE_ANSI_BRIGHT_RED: u8 = 9;
287pub const SAFE_ANSI_BRIGHT_GREEN: u8 = 10;
288pub const SAFE_ANSI_BRIGHT_MAGENTA: u8 = 13;
289pub const SAFE_ANSI_BRIGHT_CYAN: u8 = 14;
290
291/// All safe ANSI color indices as an array
292/// These 10 colors are safe to use across all common terminal themes
293pub const SAFE_ANSI_COLORS: [u8; 10] = [
294    SAFE_ANSI_RED,
295    SAFE_ANSI_GREEN,
296    SAFE_ANSI_YELLOW,
297    SAFE_ANSI_BLUE,
298    SAFE_ANSI_MAGENTA,
299    SAFE_ANSI_CYAN,
300    SAFE_ANSI_BRIGHT_RED,
301    SAFE_ANSI_BRIGHT_GREEN,
302    SAFE_ANSI_BRIGHT_MAGENTA,
303    SAFE_ANSI_BRIGHT_CYAN,
304];
305
306/// Problematic ANSI colors to avoid when safe_colors_only is enabled
307/// - 0 (black): Low contrast on dark backgrounds
308/// - 7 (white): Low contrast on light backgrounds
309/// - 8 (brblack): Invisible in Solarized Dark (hijacked for base03)
310/// - 11 (bryellow): Low contrast on light backgrounds
311/// - 12 (brblue): Low contrast in Basic Dark
312/// - 15 (brwhite): Low contrast on light backgrounds
313pub const PROBLEMATIC_ANSI_COLORS: [u8; 6] = [0, 7, 8, 11, 12, 15];
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    /// Representative terminal backgrounds the agent badge is rendered against.
320    /// A near-black dark theme (Catppuccin Mocha / Gruvbox) and a near-white
321    /// light theme (Solarized Light / Catppuccin Latte).
322    const DARK_BG: (u8, u8, u8) = (0x18, 0x18, 0x18);
323    const LIGHT_BG: (u8, u8, u8) = (0xFD, 0xF6, 0xE3);
324
325    /// Parse a `#rrggbb` token to its RGB triple. Returns `None` for anything
326    /// else (hue names fall through to the variant table below).
327    fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
328        let hex = hex.strip_prefix('#')?;
329        if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
330            return None;
331        }
332        let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
333        Some((channel(0)?, channel(2)?, channel(4)?))
334    }
335
336    /// Standard ANSI variant RGB values used by the design system for each hue
337    /// (base + bright), plus the fixed Build hex. Hue badges pick the bright
338    /// variant on dark and the base variant on light, so both appearances stay
339    /// readable; the hex renders identically on both.
340    fn hue_rgb_on(hue: &str, light: bool) -> Option<(u8, u8, u8)> {
341        if let Some(rgb) = hex_to_rgb(hue) {
342            return Some(rgb);
343        }
344        let (d, l) = match hue {
345            "red" => ((0xFF, 0x5F, 0x5F), (0xCD, 0x00, 0x00)),
346            "green" => ((0x5F, 0xFF, 0x5F), (0x00, 0xCD, 0x00)),
347            "blue" => ((0x5F, 0x5F, 0xFF), (0x00, 0x00, 0xCD)),
348            "magenta" => ((0xFF, 0x5F, 0xFF), (0xCD, 0x00, 0xCD)),
349            "yellow" => ((0xFF, 0xFF, 0x5F), (0xCD, 0xCD, 0x00)),
350            "cyan" => ((0x5F, 0xFF, 0xFF), (0x00, 0xCD, 0xCD)),
351            _ => return None,
352        };
353        Some(if light { l } else { d })
354    }
355
356    fn channel_lin(c: u8) -> f32 {
357        let c = c as f32 / 255.0;
358        if c <= 0.03928 {
359            c / 12.92
360        } else {
361            ((c + 0.055) / 1.055).powf(2.4)
362        }
363    }
364
365    fn relative_luminance((r, g, b): (u8, u8, u8)) -> f32 {
366        0.2126 * channel_lin(r) + 0.7152 * channel_lin(g) + 0.0722 * channel_lin(b)
367    }
368
369    fn contrast_ratio(fg: (u8, u8, u8), bg: (u8, u8, u8)) -> f32 {
370        let l_fg = relative_luminance(fg);
371        let l_bg = relative_luminance(bg);
372        let (hi, lo) = if l_fg > l_bg { (l_fg, l_bg) } else { (l_bg, l_fg) };
373        (hi + 0.05) / (lo + 0.05)
374    }
375
376    /// Each mode color must resolve to a variant that stays legible on its
377    /// matching background. Dark terminals clear the WCAG "large/bold text"
378    /// 3:1 bar comfortably. On light terminals the ANSI hues clear 3:1 except
379    /// green (~2:1 — the best a standard `Green` code can do against white),
380    /// and the Build hex clears 2.7:1, so the light threshold stays at 2:1.
381    const MIN_CONTRAST_DARK: f32 = 3.0;
382    const MIN_CONTRAST_LIGHT: f32 = 2.0;
383
384    fn assert_agent_hue_readable(name: &str, hue: &str, light: bool, bg: (u8, u8, u8)) {
385        let fg =
386            hue_rgb_on(hue, light).unwrap_or_else(|| panic!("{name} ({hue}) is not a supported standard ANSI hue"));
387        let cr = contrast_ratio(fg, bg);
388        let min = if light { MIN_CONTRAST_LIGHT } else { MIN_CONTRAST_DARK };
389        assert!(
390            cr >= min,
391            "{name} ({hue}) on {} bg contrast {cr:.2} below {min}",
392            if light { "light" } else { "dark" }
393        );
394    }
395
396    #[test]
397    fn agent_mode_color_tokens_are_supported() {
398        for (_, hue) in AGENT_MODE_HUE {
399            let supported = AGENT_HUE_NAMES.contains(hue) || hex_to_rgb(hue).is_some();
400            assert!(supported, "mode token {hue} is neither a standard ANSI hue nor #rrggbb");
401        }
402    }
403
404    #[test]
405    fn agent_mode_colors_are_readable_in_dark_and_light_terminals() {
406        for (mode, hue) in AGENT_MODE_HUE {
407            // Dark terminals use the bright variant; light terminals the base.
408            assert_agent_hue_readable(mode, hue, false, DARK_BG);
409            assert_agent_hue_readable(mode, hue, true, LIGHT_BG);
410        }
411    }
412
413    /// The four mode colors must be visually distinct from one another so the
414    /// user can tell Build / Auto / Plan / Duck apart at a glance.
415    #[test]
416    fn agent_mode_colors_are_distinct() {
417        let hues: Vec<&str> = AGENT_MODE_HUE.iter().map(|(_, h)| *h).collect();
418        for i in 0..hues.len() {
419            for j in (i + 1)..hues.len() {
420                assert_ne!(hues[i], hues[j], "mode hues {} and {} are identical", hues[i], hues[j]);
421            }
422        }
423    }
424
425    #[test]
426    fn agent_mode_hue_resolves_mode_names() {
427        assert_eq!(agent_mode_hue("build"), Some(AGENT_COLOR_BUILD));
428        assert_eq!(agent_mode_hue("auto"), Some(AGENT_COLOR_AUTO));
429        assert_eq!(agent_mode_hue("plan"), Some(AGENT_COLOR_PLAN));
430        assert_eq!(agent_mode_hue("duck"), Some(AGENT_COLOR_DUCK));
431        assert_eq!(agent_mode_hue("unknown"), None);
432    }
433
434    #[test]
435    fn agent_mode_colors_are_wired_into_builtin_primary_agents() {
436        // Guards against the constants drifting from the specs that expose them.
437        assert_eq!(AGENT_COLOR_BUILD, "#73a18e");
438        assert_eq!(AGENT_COLOR_AUTO, "green");
439        assert_eq!(AGENT_COLOR_PLAN, "blue");
440        assert_eq!(AGENT_COLOR_DUCK, "magenta");
441    }
442}