darq 0.1.0

darq CLI + TUI — autonomous issue → PR pipeline with SAT and a learning loop.
Documentation
#![allow(clippy::doc_overindented_list_items)]
//! ChainTopology — the assembly-line panel + agent oscilloscope.
//!
//! Four sections inside one bordered panel:
//!   1. Identity in the title:  ` CHAIN TOPOLOGY · b615cd80 · #28 retry-exponential · 14m13s `
//!   2. Stations row:           `  PLAN     IMPLEMENT  REVIEW    FIX           MERGE    SAT    LEARN  `
//!      State + elapsed:        `   ✓        ✓          ✓        ⠹ thinking    ○        ○      ○      `
//!                              `   6s       8m22s      42s      1m38s         —        —      —      `
//!   3. Connector (subtle):     `  ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈  `
//!   4. AGENT OSCILLOSCOPE:     two-row Braille decay-trace of event intensity
//!      `▁▂▁▁▂▃▅▇█▆▄▂▁▁▁▁▂▃▅▆▇█▇▅▃▂▁▁▁  scope · 30s · 42KB · +280/s`
//!   5. Live-action line:       `  ▸ FIX  ⚡ cargo build --release  1m38s  last 4s `
//!
//! The oscilloscope is the alien element — the agent's "signal" plotted as a
//! continuously-decaying trace where each event arrival pushes a spike.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};

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

const STATION_NAMES: [&str; 7] = [
    "PLAN",
    "IMPLEMENT",
    "REVIEW",
    "FIX",
    "MERGE",
    "SAT",
    "LEARN",
];
// 9 cells — index 0 is true empty (space), 1-8 are block heights.
// Zero must look DIFFERENT from "low signal" so signal pops visually.
const SPARKLINE_CELLS: [&str; 9] = [" ", "", "", "", "", "", "", "", ""];

pub fn render(frame: &mut Frame, area: Rect, app: &App, palette: &Palette) {
    // Identity goes in the title — drops the redundant "focus · " prefix and
    // the duplicate run_id rendering, packs identity densely in one line.
    let title_text = build_title(app);

    let block = Block::new()
        .borders(Borders::ALL)
        .border_style(Style::new().fg(palette.rule))
        .title(Span::styled(
            title_text,
            Style::new().fg(palette.fg_2).add_modifier(Modifier::BOLD),
        ));
    let inner = block.inner(area);
    frame.render_widget(block, area);

    let cols = inner.width as usize;
    let n = STATION_NAMES.len();
    let col_width = cols / n;

    // ── Stations row 1: name labels, no numbers (Doga: drop step numbers) ──
    let mut labels = Vec::with_capacity(n);
    for (i, name) in STATION_NAMES.iter().enumerate() {
        let style = station_label_style(app.chain_state.stations[i], palette);
        let cell = format!("{name:^width$}", width = col_width);
        labels.push(Span::styled(cell, style));
    }

    // ── Stations row 2: state glyph + (for active) " thinking" hint ──
    let elapsed_ms = if app.reduce_motion {
        0
    } else {
        app.elapsed_ms()
    };
    let glitch_idx = if app.reduce_motion {
        None
    } else {
        app.glitch.map(|(idx, _)| idx)
    };
    let mut glyphs = Vec::with_capacity(n);
    for i in 0..n {
        let st = app.chain_state.stations[i];
        let style = station_glyph_style(st, palette, elapsed_ms, glitch_idx == Some(i));
        let glyph_str: String = if glitch_idx == Some(i) {
            let frame = (elapsed_ms / 30) % 4;
            ["", "", "", ""][frame as usize].into()
        } else if matches!(st, StationState::Active) && !app.reduce_motion {
            // Active station: just the Braille spinner. The "thinking" label
            // moved to the oscilloscope (Doga: thinking is something to SEE
            // on the scope, not duplicated as text on the tile).
            let frame = (elapsed_ms / 100) as usize % Glyphs::SPINNER.len();
            Glyphs::SPINNER[frame].into()
        } else {
            st.glyph().into()
        };
        let cell = format!("{:^width$}", glyph_str, width = col_width);
        glyphs.push(Span::styled(cell, style));
    }

    // ── Stations row 3: per-station elapsed (live for active) ──
    let mut elapsed_row = Vec::with_capacity(n);
    for i in 0..n {
        let t: String = match app.chain_state.stations[i] {
            StationState::Active => {
                if let Some(hb) = &app.agent_heartbeat {
                    format_short_duration(hb.elapsed_secs * 1000)
                } else {
                    "".into()
                }
            }
            _ => match app.chain_state.elapsed_ms[i] {
                Some(ms) => format_short_duration(ms),
                None => "".into(),
            },
        };
        let cell = format!("{t:^width$}", width = col_width);
        elapsed_row.push(Span::styled(cell, Style::new().fg(palette.fg_3)));
    }

    // ── Connector — subtle dotted line, not the previously animated ─▶ packet.
    // The packet is replaced by the oscilloscope below (one motion per panel rule).
    let connector_str = "".repeat(inner.width as usize);
    let connector = Line::from(Span::styled(connector_str, Style::new().fg(palette.fg_4)));

    // ── Oscilloscope strip — 1 row of Braille bars + metadata suffix ──
    let scope_line = render_scope_line(app, palette, inner.width as usize);

    // ── Live-action line — what's happening RIGHT NOW ──
    let action_line = render_action_line(app, palette);

    // Stack everything. Total content rows: 3 (stations) + 1 connector + 1 scope + 1 action = 6.
    // Plus 1 leading blank for breathing room.
    let lines = vec![
        Line::raw(""),
        Line::from(labels),
        Line::from(glyphs),
        Line::from(elapsed_row),
        connector,
        scope_line,
        action_line,
    ];

    let para = Paragraph::new(lines).style(Style::new().bg(palette.bg_0));
    frame.render_widget(para, inner);
}

/// Title bar text — identity + total. Drops the redundant `focus · ` prefix
/// (the panel itself implies "this is the focused shift") and the second
/// run_id rendering Doga flagged as duplication.
fn build_title(app: &App) -> String {
    let Some(rid) = &app.focus_run_id else {
        return " CHAIN TOPOLOGY · idle ".into();
    };
    let short = &rid[..8.min(rid.len())];
    let info: Option<&crate::tui::app::RunInfo> = app
        .runs
        .iter()
        .chain(app.approvals.iter())
        .find(|r| r.id == *rid);
    let issue = info.map(|i| i.issue.clone()).unwrap_or_else(|| "".into());
    let dur = info
        .map(|i| i.duration.clone())
        .unwrap_or_else(|| "".into());

    let badge = if app.terminal_card.is_some() {
        " · ✓ complete"
    } else if app.approvals.iter().any(|r| r.id == *rid) {
        " · gate awaiting"
    } else {
        ""
    };
    format!(" CHAIN TOPOLOGY · {short} · {issue} · {dur}{badge} ")
}

fn station_label_style(state: StationState, palette: &Palette) -> Style {
    match state {
        StationState::Active => Style::new().fg(palette.ember).add_modifier(Modifier::BOLD),
        StationState::Completed => Style::new().fg(palette.fg_1).add_modifier(Modifier::BOLD),
        StationState::Failed => Style::new().fg(palette.red).add_modifier(Modifier::BOLD),
        StationState::Pending => Style::new().fg(palette.fg_3),
        StationState::NotApplicable => Style::new().fg(palette.fg_4),
    }
}

fn station_glyph_style(
    state: StationState,
    palette: &Palette,
    elapsed_ms: u64,
    glitching: bool,
) -> Style {
    if glitching {
        return Style::new()
            .fg(palette.red)
            .add_modifier(Modifier::BOLD | Modifier::REVERSED);
    }
    match state {
        StationState::Active => {
            let b = breathing_brightness(elapsed_ms, Motion::BEAT.as_millis() as u64);
            let t = ((b - 0.86) / 0.14).clamp(0.0, 1.0);
            let color = color_lerp(palette.ember_dim, palette.ember, t);
            Style::new().fg(color).add_modifier(Modifier::BOLD)
        }
        StationState::Completed => Style::new().fg(palette.state_pass),
        StationState::Failed => Style::new().fg(palette.red).add_modifier(Modifier::BOLD),
        StationState::Pending => Style::new().fg(palette.fg_3),
        StationState::NotApplicable => Style::new().fg(palette.fg_4),
    }
}

/// The agent oscilloscope row. Decay-trace of event intensity over ~6s of
/// render frames, sampled into block-glyph bars across the panel width.
/// The trace is downsampled when the panel is narrower than SCOPE_WIDTH —
/// each rendered cell averages M source samples.
fn render_scope_line<'a>(app: &'a App, palette: &Palette, width: usize) -> Line<'a> {
    use crate::tui::app::SCOPE_WIDTH;

    // Suffix reflects agent phase — three readable shapes have three labels.
    // Order chosen so the most informative state wins: producing > thinking > idle.
    let phase = app.agent_phase();
    let suffix = match (phase, &app.agent_heartbeat) {
        (AgentPhase::Producing, Some(hb)) => {
            let rate = hb.rate_per_sec();
            format!(
                "  scope · producing · {} · +{}/s",
                format_chars(hb.output_chars),
                rate
            )
        }
        (AgentPhase::Thinking, Some(hb)) => format!(
            "  scope · thinking · {} · last {}s",
            format_chars(hb.output_chars),
            hb.last_frame_at.elapsed().as_secs()
        ),
        _ => "  scope · idle".to_string(),
    };
    let suffix_color = match phase {
        AgentPhase::Producing => palette.cyan,
        AgentPhase::Thinking => palette.violet,
        AgentPhase::Idle => palette.fg_3,
    };
    let bar_color = match phase {
        AgentPhase::Producing => palette.cyan,
        AgentPhase::Thinking => palette.violet,
        AgentPhase::Idle => palette.fg_3,
    };
    let bar_width = width.saturating_sub(suffix.chars().count() + 2).max(8);

    // Downsample SCOPE_WIDTH samples into bar_width buckets (mean per bucket).
    let trace: Vec<f32> = app.scope_trace.iter().copied().collect();
    let mut bar = String::with_capacity(bar_width * 3);
    if trace.is_empty() {
        bar.push_str(&" ".repeat(bar_width));
    } else {
        let src_len = trace.len();
        for i in 0..bar_width {
            let lo = (i * src_len) / bar_width;
            let hi = ((i + 1) * src_len) / bar_width;
            let slice = &trace[lo..hi.max(lo + 1).min(src_len)];
            let avg: f32 = if slice.is_empty() {
                0.0
            } else {
                slice.iter().sum::<f32>() / slice.len() as f32
            };
            let idx = (avg * (SPARKLINE_CELLS.len() - 1) as f32).round() as usize;
            let idx = idx.min(SPARKLINE_CELLS.len() - 1);
            bar.push_str(SPARKLINE_CELLS[idx]);
        }
    }
    let _ = SCOPE_WIDTH; // silence unused-const warning if SCOPE_WIDTH ref dropped

    Line::from(vec![
        Span::raw(" "),
        Span::styled(bar, Style::new().fg(bar_color)),
        Span::styled(suffix, Style::new().fg(suffix_color)),
    ])
}

/// Live action line — what's happening RIGHT NOW. Pulls together: active
/// station, current tool, elapsed, last-frame-age. Empty/quiet when no shift
/// is active.
fn render_action_line<'a>(app: &'a App, palette: &Palette) -> Line<'a> {
    let active_idx = app
        .chain_state
        .stations
        .iter()
        .position(|s| matches!(s, StationState::Active));

    let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];

    if let Some(idx) = active_idx {
        let station_name = STATION_NAMES.get(idx).copied().unwrap_or("");
        spans.push(Span::styled(
            format!("{station_name}"),
            Style::new().fg(palette.ember).add_modifier(Modifier::BOLD),
        ));

        if let Some(tool) = &app.last_tool {
            // Truncate tool description to keep the line readable.
            let short_tool = if tool.chars().count() > 32 {
                let mut s = tool.chars().take(30).collect::<String>();
                s.push('');
                s
            } else {
                tool.clone()
            };
            spans.push(Span::styled("", Style::new().fg(palette.magenta)));
            spans.push(Span::styled(short_tool, Style::new().fg(palette.fg_1)));
        }

        if let Some(hb) = &app.agent_heartbeat {
            spans.push(Span::styled("  ", Style::new()));
            spans.push(Span::styled(
                format_short_duration(hb.elapsed_secs * 1000),
                Style::new().fg(palette.fg_2),
            ));
            let since = hb.last_frame_at.elapsed().as_secs();
            spans.push(Span::styled(
                format!("  last {since}s"),
                Style::new().fg(palette.fg_3),
            ));
        }
    } else if app.terminal_card.is_some() {
        spans.push(Span::styled(
            "✓ shift complete · all stations finished",
            Style::new().fg(palette.state_pass),
        ));
    } else if app.focus_run_id.is_some() {
        spans.push(Span::styled(
            "· no active station",
            Style::new().fg(palette.fg_3),
        ));
    } else {
        spans.push(Span::styled(
            "· no shift focused",
            Style::new().fg(palette.fg_3),
        ));
    }

    Line::from(spans)
}

fn format_short_duration(ms: u64) -> String {
    let s = ms / 1000;
    if s < 60 {
        format!("{s}s")
    } else if s < 3600 {
        format!("{}m{:02}s", s / 60, s % 60)
    } else {
        format!("{}h{:02}m", s / 3600, (s % 3600) / 60)
    }
}

fn format_chars(n: u64) -> String {
    if n < 1_000 {
        format!("{n}B")
    } else if n < 1_000_000 {
        format!("{}KB", n / 1_000)
    } else {
        format!("{:.1}MB", n as f64 / 1_000_000.0)
    }
}

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

    #[test]
    fn duration_formatting() {
        assert_eq!(format_short_duration(6000), "6s");
        assert_eq!(format_short_duration(502_000), "8m22s");
        assert_eq!(format_short_duration(3_700_000), "1h01m");
    }

    #[test]
    fn station_names_count_is_seven() {
        assert_eq!(STATION_NAMES.len(), 7);
    }

    #[test]
    fn sparkline_cells_cover_full_range() {
        assert!(SPARKLINE_CELLS.len() >= 8);
    }
}