darq 0.1.0

darq CLI + TUI — autonomous issue → PR pipeline with SAT and a learning loop.
Documentation
//! HeaderBar — 1-row top of the TUI.
//!
//! Layout: `darq // INDUSTRIAL CODE FORGE v<X.Y.Z>  shifts N/M  tools N  blueprints N  throughput [▁▃▆█]  ⠋ ● HEARTBEAT  uptime <Xd YYh>`

use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;

use crate::tui::app::App;
use crate::tui::theme::{Glyphs, Motion, Palette, breathing_brightness, color_lerp};

/// Render the header into the given area. Single row, three sections:
/// brand (left, fixed) · counters (middle, flex) · heartbeat (right, anchored).
pub fn render(frame: &mut Frame, area: Rect, app: &App, palette: &Palette) {
    let active_shifts = app
        .runs
        .iter()
        .filter(|r| r.status == "running" || r.status == "awaiting_approval")
        .count();
    let total_shifts = app.runs.len() + app.approvals.len();
    let tools = app.tool_count;
    // Prefer per-shift retrieved blueprints if any; else fall back to daemon-wide
    // store count (populated by Method::Stats — Phase 5.7/5.8).
    let blueprints = if app.blueprints.is_empty() {
        app.daemon_blueprint_count as usize
    } else {
        app.blueprints.len()
    };
    let uptime_str = format_uptime(app.daemon_uptime_secs);

    // Spinner frame from tick count.
    let spinner_frame = Glyphs::SPINNER[(app.tick_count as usize) % Glyphs::SPINNER.len()];

    // Throughput sparkline — 16 most recent ticks → block-glyphs.
    let sparkline = render_sparkline(&app.throughput_window, 16);

    let version = env!("CARGO_PKG_VERSION");

    // Brand — let the terminology + run state do the work; no tagline.
    let brand = vec![
        Span::styled(
            "darq",
            Style::new()
                .fg(palette.brand_green)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw(" "),
        Span::styled(format!("v{version}"), Style::new().fg(palette.fg_3)),
    ];

    // Counters (mid). Counts use real numbers (0 not —); throughput is always
    // a sparkline; uptime hidden in replay (daemon-only).
    let mut mid = vec![
        Span::styled("  shifts ", Style::new().fg(palette.fg_3)),
        Span::styled(
            format!("{active_shifts}/{total_shifts}"),
            Style::new().fg(palette.fg_0),
        ),
        Span::styled("  tools ", Style::new().fg(palette.fg_3)),
        Span::styled(tools.to_string(), Style::new().fg(palette.fg_0)),
        Span::styled("  blueprints ", Style::new().fg(palette.fg_3)),
        Span::styled(blueprints.to_string(), Style::new().fg(palette.fg_0)),
        Span::styled("  throughput ", Style::new().fg(palette.fg_3)),
        Span::styled(sparkline, Style::new().fg(palette.cyan)),
    ];
    if !app.is_replay {
        mid.push(Span::styled("  uptime ", Style::new().fg(palette.fg_3)));
        mid.push(Span::styled(uptime_str, Style::new().fg(palette.fg_0)));
    }

    // Heartbeat right side. Pulsing dot — modulate brightness via sin wave.
    // truecolor terminals see smooth lerp; 256c gets stepped at midpoint.
    let pulse_b = breathing_brightness(app.elapsed_ms(), Motion::BEAT.as_millis() as u64);
    // Map [0.86, 1.0] to lerp factor [0.6, 0.0] — at peak: full heartbeat;
    // at trough: 60% lerp toward bg_1 (clearly dimmer but still pink).
    let pulse_t = ((1.0 - pulse_b) / 0.14 * 0.6).clamp(0.0, 0.6);
    let dot_color = color_lerp(palette.heartbeat, palette.bg_1, pulse_t);

    let mut right = vec![
        Span::styled(spinner_frame, Style::new().fg(palette.heartbeat)),
        Span::raw(" "),
        Span::styled(
            Glyphs::HEARTBEAT_DOT,
            Style::new().fg(dot_color).add_modifier(Modifier::BOLD),
        ),
        Span::raw(" "),
        Span::styled(
            "HEARTBEAT",
            Style::new()
                .fg(palette.heartbeat)
                .add_modifier(Modifier::BOLD),
        ),
    ];
    if app.failure_dot_until.is_some() {
        right.push(Span::raw("  "));
        right.push(Span::styled(
            "",
            Style::new().fg(palette.red).add_modifier(Modifier::BOLD),
        ));
    }

    // Compute display widths to size the right pane exactly, leaving everything
    // else (brand + counters) to flex on the left as one continuous span. Avoids
    // the 3-column tearing where brand_width could miscalculate and clip the
    // first character.
    let right_width: u16 = right
        .iter()
        .map(|s| s.content.chars().count())
        .sum::<usize>() as u16;

    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Min(0), Constraint::Length(right_width)])
        .split(area);

    let bg = Style::new().bg(palette.bg_0);
    let mut left_spans = brand;
    left_spans.extend(mid);
    frame.render_widget(
        Paragraph::new(Line::from(left_spans))
            .style(bg)
            .alignment(Alignment::Left),
        cols[0],
    );
    frame.render_widget(
        Paragraph::new(Line::from(right))
            .style(bg)
            .alignment(Alignment::Right),
        cols[1],
    );
}

/// Format daemon uptime in compact `Xd YYh` form, or `—` if not yet known.
fn format_uptime(secs: u64) -> String {
    if secs == 0 {
        return "".into();
    }
    let days = secs / 86_400;
    let hours = (secs % 86_400) / 3600;
    let mins = (secs % 3600) / 60;
    if days > 0 {
        format!("{days}d {hours:02}h")
    } else if hours > 0 {
        format!("{hours}h {mins:02}m")
    } else {
        format!("{mins}m")
    }
}

/// Render the rolling throughput window as a sparkline string of `width` cells.
fn render_sparkline(window: &std::collections::VecDeque<u32>, width: usize) -> String {
    if window.is_empty() {
        return "".repeat(width);
    }
    let max = *window.iter().max().unwrap_or(&1).max(&1) as f32;
    let cells = Glyphs::SPARKLINE.len();

    // Take the last `width` entries (right-aligned in time).
    let n = window.len();
    let start = n.saturating_sub(width);
    let mut out = String::with_capacity(width * 3); // utf-8 cells

    // Pad-left if window shorter than width.
    if n < width {
        for _ in 0..(width - n) {
            out.push('');
        }
    }
    for &v in window.iter().skip(start) {
        let normalized = (v as f32 / max).clamp(0.0, 1.0);
        let idx = ((normalized * (cells as f32 - 1.0)).round() as usize).min(cells - 1);
        out.push_str(Glyphs::SPARKLINE[idx]);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::VecDeque;

    #[test]
    fn sparkline_empty_returns_low_cells() {
        let window: VecDeque<u32> = VecDeque::new();
        let s = render_sparkline(&window, 8);
        assert_eq!(s.chars().count(), 8);
    }

    #[test]
    fn sparkline_normalizes_to_max() {
        let window: VecDeque<u32> = vec![0, 5, 10].into();
        let s = render_sparkline(&window, 3);
        assert_eq!(s.chars().count(), 3);
        // max value should produce the tallest cell
        assert!(s.ends_with(""));
    }

    #[test]
    fn sparkline_pads_left_when_window_short() {
        let window: VecDeque<u32> = vec![5].into();
        let s = render_sparkline(&window, 4);
        assert_eq!(s.chars().count(), 4);
        assert!(s.starts_with(""));
    }
}