collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Boot-time splash screen rendered to stdout before the TUI takes over.
//!
//! A background thread drives the animation; the main thread sends progress
//! updates through a channel.  Call `SplashScreen::finish()` when the app is
//! ready to enter the TUI — it stops the animation and clears the output so
//! the TUI starts with a clean screen.

use std::io::{self, Write};
use std::sync::mpsc;
use std::time::Duration;

// ── ANSI helpers (no crossterm needed before TUI is up) ──────────────────────

const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const GREEN: &str = "\x1b[38;2;95;215;135m";
const CYAN: &str = "\x1b[38;2;100;210;230m";
const YELLOW: &str = "\x1b[38;2;230;190;80m";
const GRAY: &str = "\x1b[38;2;110;110;120m";
const WHITE: &str = "\x1b[38;2;220;220;220m";

const SPINNER: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
const TICK_MS: u64 = 80;

// Rotating taglines — one shown per run for variety.
const TAGLINES: &[&str] = &[
    "ship code. not excuses.",
    "your relentless coding partner",
    "think it. plan it. ship it.",
    "code without limits",
    "move fast. break nothing.",
    "from idea to diff in seconds",
    "the agent that never sleeps",
    "your codebase, amplified",
];

// Random tips shown while waiting — cycle through so repeat runs feel fresh.
const TIPS: &[&str] = &[
    "/architect splits planning from coding",
    "/proceed executes a saved plan",
    "/rewind rolls back file changes",
    "Alt+Y cycles approval mode",
    "Tab switches agent configs",
    "Esc × 2 cancels a running agent",
    "/resume loads a previous session",
    "/hive spawns parallel agents",
    "/clear resets conversation context",
    "Shift+Enter for multi-line input",
    "/save-plan stores a plan to disk",
    "Double-Esc also works as cancel",
];

// ── Message protocol ──────────────────────────────────────────────────────────

enum Msg {
    Step(String),
    Done,
}

// ── Public API ────────────────────────────────────────────────────────────────

/// Cheap sender passed into `App::new_with_progress`.
#[derive(Clone)]
pub struct SplashSender(mpsc::SyncSender<Msg>);

impl SplashSender {
    /// Advance the displayed loading step.
    pub fn step(&self, label: &str) {
        let _ = self.0.try_send(Msg::Step(label.to_string()));
    }
}

/// Owns the background render thread.  Drop or call `finish()` to stop.
pub struct SplashScreen {
    tx: mpsc::SyncSender<Msg>,
    thread: Option<std::thread::JoinHandle<()>>,
}

impl SplashScreen {
    /// Start the splash screen and return a sender for progress updates.
    pub fn start() -> (Self, SplashSender) {
        let (tx, rx) = mpsc::sync_channel::<Msg>(64);
        let sender = SplashSender(tx.clone());
        let thread = std::thread::spawn(move || render_loop(rx));
        (
            Self {
                tx,
                thread: Some(thread),
            },
            sender,
        )
    }

    /// Signal completion and wait for the thread to clean up.
    pub fn finish(mut self) {
        let _ = self.tx.try_send(Msg::Done);
        if let Some(t) = self.thread.take() {
            let _ = t.join();
        }
    }
}

impl Drop for SplashScreen {
    fn drop(&mut self) {
        let _ = self.tx.try_send(Msg::Done);
        if let Some(t) = self.thread.take() {
            let _ = t.join();
        }
    }
}

// ── Render thread ─────────────────────────────────────────────────────────────

fn render_loop(rx: mpsc::Receiver<Msg>) {
    // Pick a tagline and tip based on process start time for variety across runs.
    let now_secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as usize)
        .unwrap_or(0);
    let tagline = TAGLINES[now_secs % TAGLINES.len()];
    let tip = TIPS[(now_secs / 13) % TIPS.len()];

    let term_width = term_width();
    let box_width = term_width.clamp(52, 76);

    // Print the static header once.
    print_header(box_width, tagline);
    let _ = io::stdout().flush();

    let mut completed: Vec<String> = Vec::new();
    let mut current: Option<String> = None;
    let mut frame: usize = 0;
    let mut prev_lines: usize = 0;

    loop {
        // Drain all pending messages non-blocking.
        let mut done = false;
        loop {
            match rx.try_recv() {
                Ok(Msg::Step(s)) => {
                    if let Some(prev) = current.take() {
                        completed.push(prev);
                    }
                    current = Some(s);
                }
                Ok(Msg::Done) => {
                    done = true;
                    break;
                }
                Err(_) => break,
            }
        }

        // Move cursor back up over the dynamic area.
        if prev_lines > 0 {
            print!("\x1b[{}A", prev_lines);
        }

        if done {
            // Final render: all steps completed.
            if let Some(last) = current.take() {
                completed.push(last);
            }
            prev_lines = render_steps(&completed, None, frame, box_width, tip);
            let _ = io::stdout().flush();
            // Brief pause so the final state is visible.
            std::thread::sleep(Duration::from_millis(120));
            // Clear the dynamic area before TUI takes over.
            for _ in 0..prev_lines {
                print!("\r\x1b[K\x1b[1A");
            }
            // Also clear the static header (HEADER_LINES lines).
            for _ in 0..HEADER_LINES {
                print!("\r\x1b[K\x1b[1A");
            }
            print!("\r\x1b[K"); // clear the very top line
            let _ = io::stdout().flush();
            return;
        }

        prev_lines = render_steps(&completed, current.as_deref(), frame, box_width, tip);
        let _ = io::stdout().flush();

        // Check for Done with a short timeout (non-blocking select).
        match rx.recv_timeout(Duration::from_millis(TICK_MS)) {
            Ok(Msg::Step(s)) => {
                if let Some(prev) = current.take() {
                    completed.push(prev);
                }
                current = Some(s);
            }
            Ok(Msg::Done) => {
                // Will be picked up on next drain loop above.
                if let Some(last) = current.take() {
                    completed.push(last);
                }
                // Final render.
                if prev_lines > 0 {
                    print!("\x1b[{}A", prev_lines);
                }
                prev_lines = render_steps(&completed, None, frame, box_width, tip);
                let _ = io::stdout().flush();
                std::thread::sleep(Duration::from_millis(120));
                for _ in 0..prev_lines {
                    print!("\r\x1b[K\x1b[1A");
                }
                for _ in 0..HEADER_LINES {
                    print!("\r\x1b[K\x1b[1A");
                }
                print!("\r\x1b[K");
                let _ = io::stdout().flush();
                return;
            }
            Err(_) => {}
        }

        frame = frame.wrapping_add(1);
    }
}

// ── Static header ─────────────────────────────────────────────────────────────

/// Number of lines in the static header block.
const HEADER_LINES: usize = 7;

fn print_header(box_width: usize, tagline: &str) {
    let inner = box_width - 2;
    let version = env!("CARGO_PKG_VERSION");

    let top = format!("{CYAN}{}{RESET}", "".repeat(inner));
    let bot = format!("{CYAN}{}{RESET}", "".repeat(inner));
    let blank = format!("{CYAN}{}{CYAN}{RESET}", " ".repeat(inner));

    // Title row: "  ◆  collet  v0.1.0"
    let title_raw = format!("  ◆  collet  v{version}");
    let title_vis = visible_width(&title_raw);
    let title_pad = inner.saturating_sub(title_vis + 1);
    let title = format!(
        "{CYAN}{RESET} {BOLD}{CYAN}{WHITE}collet{RESET}  {GRAY}v{version}{RESET}{}{CYAN}{RESET}",
        " ".repeat(title_pad)
    );

    // Subtitle row: rotating tagline
    let sub_raw = format!("     {tagline}");
    let sub_vis = visible_width(&sub_raw);
    let sub_pad = inner.saturating_sub(sub_vis + 1);
    let subtitle = format!(
        "{CYAN}{RESET} {DIM}{WHITE}{sub_raw}{RESET}{}{CYAN}{RESET}",
        " ".repeat(sub_pad)
    );

    println!();
    println!("{top}");
    println!("{title}");
    println!("{subtitle}");
    println!("{blank}");
    println!("{bot}");
    println!();
}

// ── Dynamic step area ─────────────────────────────────────────────────────────

/// Render the dynamic steps area.  Returns the number of lines printed.
fn render_steps(
    completed: &[String],
    current: Option<&str>,
    frame: usize,
    box_width: usize,
    tip: &str,
) -> usize {
    let mut lines = 0;

    // Completed steps.
    for step in completed {
        let vis = visible_width(step);
        let pad = box_width.saturating_sub(vis + 8);
        println!(
            "\r\x1b[K  {GREEN}{RESET}  {DIM}{step}{RESET}{}",
            " ".repeat(pad)
        );
        lines += 1;
    }

    // Current (animated) step.
    if let Some(step) = current {
        let sp = SPINNER[(frame / 2) % SPINNER.len()];
        let vis = visible_width(step);
        let pad = box_width.saturating_sub(vis + 8);
        println!("\r\x1b[K  {YELLOW}{sp}{RESET}  {step}{}", " ".repeat(pad));
        lines += 1;
    }

    // Blank + tip line.
    println!("\r\x1b[K");
    println!("\r\x1b[K  {GRAY}tip  {tip}{RESET}");
    lines += 2;

    lines
}

// ── Helpers ───────────────────────────────────────────────────────────────────

fn term_width() -> usize {
    // crossterm::terminal::size() returns (cols, rows)
    crossterm::terminal::size()
        .map(|(w, _)| w as usize)
        .unwrap_or(80)
}

/// Approximate visible character width, stripping ANSI escape sequences.
pub fn visible_width(s: &str) -> usize {
    let mut width = 0;
    let mut in_escape = false;
    for ch in s.chars() {
        if ch == '\x1b' {
            in_escape = true;
        } else if in_escape {
            if ch.is_alphabetic() {
                in_escape = false;
            }
        } else {
            width += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
        }
    }
    width
}