use crate::color; use crate::color::rgb; use ratatui::prelude::*; pub fn user_color() -> Color { rgb(138, 180, 248) } pub fn ai_color() -> Color { rgb(129, 199, 132) } pub fn tool_color() -> Color { rgb(120, 120, 120) } pub fn file_link_color() -> Color { rgb(180, 200, 255) } pub fn dim_color() -> Color { rgb(80, 80, 80) } pub fn accent_color() -> Color { rgb(186, 139, 255) } pub fn system_message_color() -> Color { rgb(255, 170, 220) } pub fn queued_color() -> Color { rgb(255, 193, 7) } pub fn asap_color() -> Color { rgb(110, 210, 255) } pub fn pending_color() -> Color { rgb(140, 140, 140) } pub fn user_text() -> Color { rgb(245, 245, 255) } pub fn user_bg() -> Color { rgb(35, 40, 50) } pub fn ai_text() -> Color { rgb(220, 220, 215) } pub fn header_icon_color() -> Color { rgb(120, 210, 230) } pub fn header_name_color() -> Color { rgb(190, 210, 235) } pub fn header_session_color() -> Color { rgb(255, 255, 255) } // Spinner frames for animated status. Keep these single-cell because the fast // spinner-only renderer patches one status cell between full TUI redraws. This // sequence should read as a circular spin, not a grow/recede pulse. const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /// Frame rate for slow, full-line "liveness" indicators that can only be /// repainted by a full TUI redraw (e.g. the running-tool progress bar) when /// decorative animations are disabled (Minimal tier, SSH, WSL, etc.). These /// ride the ~1 Hz passive-liveness redraw, so advancing them faster would just /// skip frames. Keep this slow so they read as alive without forcing more /// expensive full-frame redraws. pub const LIVENESS_INDICATOR_FPS: f32 = 1.5; /// Frame rate for the low-cost single-cell circular spinner when decorative /// animations are disabled. Unlike the full-line indicators above, this spinner /// is patched by the cheap one-cell fast path between full redraws, so it can /// animate at a smooth, responsive cadence (well above ~1 Hz) while still /// staying very light on resources. Keep this in sync with the spinner-only /// tick interval in the TUI run loop (`STATUS_SPINNER_ONLY_INTERVAL`, 80ms) so /// each tick lands on exactly one new frame. pub const LIVENESS_SPINNER_FPS: f32 = 12.5; pub fn spinner_frame_index(elapsed: f32, fps: f32) -> usize { ((elapsed * fps) as usize) % SPINNER_FRAMES.len() } pub fn spinner_frame(elapsed: f32, fps: f32) -> &'static str { SPINNER_FRAMES[spinner_frame_index(elapsed, fps)] } pub fn activity_indicator_frame_index( elapsed: f32, fps: f32, enable_decorative_animations: bool, ) -> usize { if enable_decorative_animations { spinner_frame_index(elapsed, fps) } else { // Keep ticking at the smooth liveness rate instead of freezing on a // single frame. The single-cell fast path repaints this cheaply, so it // can animate well above ~1 Hz without a full-frame redraw. spinner_frame_index(elapsed, LIVENESS_SPINNER_FPS) } } pub fn activity_indicator( elapsed: f32, fps: f32, enable_decorative_animations: bool, ) -> &'static str { SPINNER_FRAMES[activity_indicator_frame_index(elapsed, fps, enable_decorative_animations)] } /// Convert HSL to RGB (h in 0-360, s and l in 0-1) /// Chroma color based on position and time - creates flowing rainbow wave /// Calculate chroma color with fade-in from dim during startup /// Calculate smooth animated color for the header (single color, no position) pub fn color_to_floats(c: Color, fallback: (f32, f32, f32)) -> (f32, f32, f32) { match c { Color::Rgb(r, g, b) => (r as f32, g as f32, b as f32), Color::Indexed(n) => { let (r, g, b) = color::indexed_to_rgb(n); (r as f32, g as f32, b as f32) } _ => fallback, } } pub fn blend_color(from: Color, to: Color, t: f32) -> Color { let (fr, fg, fb) = color_to_floats(from, (80.0, 80.0, 80.0)); let (tr, tg, tb) = color_to_floats(to, (200.0, 200.0, 200.0)); let r = fr + (tr - fr) * t; let g = fg + (tg - fg) * t; let b = fb + (tb - fb) * t; rgb( r.clamp(0.0, 255.0) as u8, g.clamp(0.0, 255.0) as u8, b.clamp(0.0, 255.0) as u8, ) } pub fn rainbow_prompt_color(distance: usize) -> Color { // Rainbow colors (hue progression): red -> orange -> yellow -> green -> cyan -> blue -> violet const RAINBOW: [(u8, u8, u8); 7] = [ (255, 80, 80), // Red (softened) (255, 160, 80), // Orange (255, 230, 80), // Yellow (80, 220, 100), // Green (80, 200, 220), // Cyan (100, 140, 255), // Blue (180, 100, 255), // Violet ]; // Gray target (dim_color()) const GRAY: (u8, u8, u8) = (80, 80, 80); // Exponential decay factor - how quickly we fade to gray // decay = e^(-distance * rate), rate of ~0.4 gives nice falloff let decay = (-0.4 * distance as f32).exp(); // Select rainbow color based on distance (cycle through) let rainbow_idx = distance.min(RAINBOW.len() - 1); let (r, g, b) = RAINBOW[rainbow_idx]; // Blend rainbow color with gray based on decay // At distance 0: 100% rainbow, as distance increases: approaches gray let blend = |rainbow: u8, gray: u8| -> u8 { (rainbow as f32 * decay + gray as f32 * (1.0 - decay)) as u8 }; rgb(blend(r, GRAY.0), blend(g, GRAY.1), blend(b, GRAY.2)) } pub fn prompt_entry_color(base: Color, t: f32) -> Color { let peak = rgb(255, 230, 120); // Quick pulse in/out over the animation window. let phase = if t < 0.5 { t * 2.0 } else { (1.0 - t) * 2.0 }; blend_color(base, peak, phase.clamp(0.0, 1.0) * 0.7) } pub fn prompt_entry_bg_color(base: Color, t: f32) -> Color { let spotlight = rgb(58, 66, 82); let ease_in = 1.0 - (1.0 - t).powi(3); let ease_out = (1.0 - t).powi(2); let phase = (ease_in * ease_out * 1.65).clamp(0.0, 1.0); blend_color(base, spotlight, phase * 0.85) } pub fn prompt_entry_shimmer_color(base: Color, pos: f32, t: f32) -> Color { let travel = (t * 1.15).clamp(0.0, 1.0); let width = 0.18; let dist = (pos - travel).abs(); let shimmer = (1.0 - (dist / width).clamp(0.0, 1.0)).powf(2.2); let pulse = (1.0 - t).powf(0.55); let highlight = rgb(255, 248, 210); blend_color(base, highlight, shimmer * pulse * 0.7) } /// Generate an animated color that pulses between two colors pub fn animated_tool_color(elapsed: f32, enable_decorative_animations: bool) -> Color { if !enable_decorative_animations { return tool_color(); } // Cycle period of ~1.5 seconds let t = (elapsed * 2.0).sin() * 0.5 + 0.5; // 0.0 to 1.0 // Interpolate between cyan and purple let r = (80.0 + t * 106.0) as u8; // 80 -> 186 let g = (200.0 - t * 61.0) as u8; // 200 -> 139 let b = (220.0 + t * 35.0) as u8; // 220 -> 255 rgb(r, g, b) } #[cfg(test)] mod tests { use super::*; #[test] fn spinner_frames_are_circular_braille_sequence() { assert_eq!( SPINNER_FRAMES, &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] ); } #[test] fn spinner_frame_wraps_at_sequence_length() { let fps = 10.0; assert_eq!(spinner_frame(0.0, fps), "⠋"); assert_eq!(spinner_frame(0.9, fps), "⠏"); assert_eq!(spinner_frame(1.0, fps), "⠋"); } #[test] fn activity_indicator_still_advances_without_decorative_animations() { // With decorative animations disabled the single-cell spinner must keep // ticking instead of freezing on one frame. let first = activity_indicator(0.0, 12.5, false); let later = activity_indicator(1.0, 12.5, false); assert!(SPINNER_FRAMES.contains(&first)); assert_ne!( first, later, "liveness spinner should advance within one second" ); } #[test] fn liveness_spinner_advances_smoothly_within_a_few_frames() { // The single-cell fast path patches one status cell per 80ms tick, so the // non-decorative liveness spinner should advance well faster than ~1 Hz // (it should not still read as frozen between consecutive fast-path ticks). let frame_at = |elapsed: f32| activity_indicator(elapsed, 12.5, false); // One 80ms fast-path tick should already move to the next frame. assert_ne!( frame_at(0.0), frame_at(0.08), "liveness spinner should advance every fast-path tick (80ms)" ); // It must be meaningfully faster than the old ~1.5 Hz cadence. const { assert!( LIVENESS_SPINNER_FPS >= 8.0, "liveness spinner should animate at a smooth, responsive rate" ); } } }